From 0c552eecac0469523a74869fc8597502a3e8a679 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 05:42:59 +0200 Subject: [PATCH 01/43] =?UTF-8?q?fix(world):=20honour=20SetOmega=20?= =?UTF-8?q?=E2=80=94=20the=20birds=20and=20butterflies=20fly=20again?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ambient flyers played their wing animation and stayed put. A Static object whose Setup declares a DefaultAnimation joins retail's CPhysics::static_animating_objects workset (CPhysicsObj::InitDefaults @0x00513A7B) and is driven by animate_static_object @0x00513DF0. That function has exactly one motion step: CPartArray::Update(part_array, dt, nullptr); // animate Frame::grotate(&this->m_position.frame, &this->m_omegaVector); Note the nullptr: unlike UpdatePositionInternal @0x00512C30, which combines the animation's accumulated frame into the object's position, the static branch DISCARDS it. These objects cannot move by animation translation at all. The omega vector is the whole mechanism, and one thing writes it — SetOmegaHook::Execute @0x00526F30 -> CPhysicsObj::set_omega @0x0050F6D0. We decoded that hook and then dropped it on the floor: IAnimationHookSink's own docs list SetOmegaHook among the unwired ones, and PhysicsBody.Omega was assigned nowhere outside projectiles. The scheduler's GRotate call was already correct — it was multiplying by a permanent zero. The hook is now applied to the owning body at process_hooks time. Retail runs process_hooks AFTER the grotate in the same pass, so a newly-set omega first takes effect on the following frame; our Tick/ProcessHooks split already had that order. Scoped from the data rather than guessed. tools/AnimHookScan (new) walks the dat: of 2,066 animations exactly 8 contain SetOmega, and all 8 are the DefaultAnimation of one of the 8 setups that use it. No creature animation uses it, so this belongs precisely where body.Omega is read and nowhere else. The same scan is why the fix is believable as FLIGHT rather than a pirouette. Every authored omega is pure yaw, and the setups' parts sit 5.6m, 4.2m, 12m and 36.8m from the origin they spin about. Rotating a frame whose mesh hangs 12m off-axis carries it around a 12m circle — that offset IS the flight radius. An installed-DAT test pins both properties, because the fix is only correct while they hold and neither is visible from the code. Also checked and deliberately NOT conflated: CSequence::set_omega @0x005248A0 writes CSequence::omega, a different field from CPhysicsObj::m_omegaVector, fed by the motion table for creature turning. Only the latter drives grotate. Solution builds clean; 14,473 tests pass on the standard hermetic lane filter plus the new installed-DAT test, 0 failures. Co-Authored-By: Claude Opus 5 --- .../RetailStaticAnimatingObjectScheduler.cs | 50 +++ .../Physics/AnimationSequencer.cs | 14 + .../StaticAnimatingOmegaHookTests.cs | 57 ++++ .../InstalledStaticAnimatingOmegaTests.cs | 100 ++++++ tools/AnimHookScan/AnimHookScan.csproj | 13 + tools/AnimHookScan/Program.cs | 105 ++++++ tools/AnimHookScan/packages.neutral.lock.json | 304 ++++++++++++++++++ 7 files changed, 643 insertions(+) create mode 100644 tests/AcDream.App.Tests/Rendering/StaticAnimatingOmegaHookTests.cs create mode 100644 tests/AcDream.Content.Tests/InstalledStaticAnimatingOmegaTests.cs create mode 100644 tools/AnimHookScan/AnimHookScan.csproj create mode 100644 tools/AnimHookScan/Program.cs create mode 100644 tools/AnimHookScan/packages.neutral.lock.json diff --git a/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs index ca3f1b8a..23823d86 100644 --- a/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs +++ b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs @@ -516,6 +516,16 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram continue; } + // SetOmegaHook::Execute 0x00526F30 -> CPhysicsObj::set_omega + // 0x0050F6D0 writes m_omegaVector on the owning physics object, + // which animate_static_object then feeds to Frame::grotate every + // frame. Apply it BEFORE the presentation sink drains the queue. + // + // Retail runs process_hooks AFTER the grotate in the same pass, so + // a newly-set omega first takes effect on the following frame; our + // Tick/ProcessHooks split preserves that ordering. + ApplyOmegaHooks(owner, sequencer.PendingHooks); + // Clear before the callback: hook delivery may unregister or // replace the owner, and a nested caller must not replay this tail. owner.PendingProcessHooks = null; @@ -523,6 +533,46 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram } } + private static void ApplyOmegaHooks(Owner owner, IReadOnlyList hooks) + { + if (owner.Body is not { } body) + return; + if (TryResolveOmega(hooks, out Vector3 omega)) + body.Omega = omega; + } + + /// + /// The omega a hook batch leaves on the physics object, if any. + /// + /// + /// + /// This is the mechanism behind AC's circling birds and flitting + /// butterflies, and it is not a translation: every authored omega in the + /// dat is pure yaw, and the setup's parts sit well off the origin (5.6 m to + /// 36.8 m across the eight setups that use it). Spinning a frame whose mesh + /// hangs metres off the axis carries that mesh around a circle of the same + /// radius, which is what reads as flight. + /// + /// + /// Last hook wins: retail executes a batch in order and every + /// set_omega overwrites the vector outright rather than accumulating. + /// + /// + internal static bool TryResolveOmega( + IReadOnlyList hooks, out Vector3 omega) + { + omega = default; + bool found = false; + for (int i = 0; i < hooks.Count; i++) + { + if (hooks[i] is not SetOmegaHook set) + continue; + omega = new Vector3(set.Axis.X, set.Axis.Y, set.Axis.Z); + found = true; + } + return found; + } + private bool IsResidentAtVersion(Owner owner, ulong version) => _isResident(owner.Entity) && _residencyVersion(owner.Entity) == version; diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index 676c20f8..89b3f8f3 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -548,6 +548,20 @@ public sealed class AnimationSequencer /// Empty when no frame boundary was crossed. Safe to call multiple /// times per frame; second and subsequent calls return an empty list. /// + /// + /// The hooks that have fired since the last , + /// WITHOUT draining them. + /// + /// + /// Retail's CPhysicsObj::process_hooks executes every queued hook + /// against the owning CPhysicsObj, and a hook may act on BOTH the + /// physics object and presentation — SetOmegaHook::Execute + /// (0x00526F30) writes m_omegaVector. Draining the queue for + /// the presentation sink would hide those hooks from the physics owner, so + /// the owner peeks here and the sink still consumes the complete stream. + /// + public IReadOnlyList PendingHooks => _pendingHooks; + public IReadOnlyList ConsumePendingHooks() { if (_pendingHooks.Count == 0) diff --git a/tests/AcDream.App.Tests/Rendering/StaticAnimatingOmegaHookTests.cs b/tests/AcDream.App.Tests/Rendering/StaticAnimatingOmegaHookTests.cs new file mode 100644 index 00000000..324cb832 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/StaticAnimatingOmegaHookTests.cs @@ -0,0 +1,57 @@ +using System.Numerics; +using AcDream.App.Rendering; +using DatReaderWriter.Types; + +namespace AcDream.App.Tests.Rendering; + +/// +/// The SetOmega hook is what moves AC's ambient flyers; ignoring it left the +/// birds and butterflies animating in place. +/// +public sealed class StaticAnimatingOmegaHookTests +{ + private static SetOmegaHook Omega(float x, float y, float z) + => new() { Axis = new Vector3(x, y, z) }; + + [Fact] + public void NoHooksLeavesOmegaUntouched() + { + Assert.False( + RetailStaticAnimatingObjectScheduler.TryResolveOmega([], out _)); + } + + [Fact] + public void AHookBatchWithoutSetOmegaLeavesOmegaUntouched() + { + // A batch that carries other hooks must not zero a previously set + // omega: retail's set_omega is only ever called BY the hook. + Assert.False( + RetailStaticAnimatingObjectScheduler.TryResolveOmega( + [new SoundTableHook()], out _)); + } + + [Fact] + public void SetOmegaIsTakenVerbatim() + { + Assert.True( + RetailStaticAnimatingObjectScheduler.TryResolveOmega( + [Omega(0f, 0f, -0.027f)], out Vector3 omega)); + + // CPhysicsObj::set_omega @ 0x0050F6D0 assigns the axis outright — no + // scaling, and animate_static_object does not multiply it by elapsed + // time either. + Assert.Equal(new Vector3(0f, 0f, -0.027f), omega); + } + + [Fact] + public void TheLastSetOmegaInABatchWins() + { + // Retail executes a hook batch in order and each set_omega overwrites + // the vector outright rather than accumulating. + Assert.True( + RetailStaticAnimatingObjectScheduler.TryResolveOmega( + [Omega(0f, 0f, -0.02f), Omega(0f, 0f, 0.05f)], + out Vector3 omega)); + Assert.Equal(new Vector3(0f, 0f, 0.05f), omega); + } +} diff --git a/tests/AcDream.Content.Tests/InstalledStaticAnimatingOmegaTests.cs b/tests/AcDream.Content.Tests/InstalledStaticAnimatingOmegaTests.cs new file mode 100644 index 00000000..3ae1c42e --- /dev/null +++ b/tests/AcDream.Content.Tests/InstalledStaticAnimatingOmegaTests.cs @@ -0,0 +1,100 @@ +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Options; +using DatReaderWriter.Types; + +namespace AcDream.Content.Tests; + +/// +/// Pins the data the ambient flyers (circling birds, flitting butterflies) +/// depend on, against the installed retail DATs. +/// +/// +/// +/// A Static object whose Setup declares a DefaultAnimation joins retail's +/// CPhysics::static_animating_objects workset +/// (CPhysicsObj::InitDefaults @ 0x00513A7B) and is driven by +/// animate_static_object @ 0x00513DF0, whose only motion step is +/// Frame::grotate(&m_position.frame, &m_omegaVector). That +/// vector is written by exactly one thing: +/// SetOmegaHook::Execute @ 0x00526F30CPhysicsObj::set_omega +/// @ 0x0050F6D0. +/// +/// +/// So these objects move ONLY if the SetOmega hook is honoured. acdream +/// decoded the hook and then ignored it, leaving m_omegaVector at zero: the +/// wings animated and nothing flew. +/// +/// +/// This test exists because the fix rests on two claims about shipped data +/// that are invisible from the code: that the authored omegas are pure YAW, +/// and that the meshes hang far off the axis they spin about. Rotation only +/// reads as flight because of the second one — a bird spinning about its own +/// centre would just pirouette. If a dat ever contradicts either, the fix is +/// wrong and this should say so rather than the behaviour quietly changing. +/// +/// +[Trait("Lane", "InstalledDat")] +public sealed class InstalledStaticAnimatingOmegaTests +{ + [Fact] + public void EverySetOmegaAnimationIsPureYawOnAMeshOffsetFromItsAxis() + { + string? datDir = ContentConformanceDats.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); + + var found = new List<(uint Setup, float Yaw, double Radius)>(); + + foreach (uint id in dats.Portal.Tree.Select(e => e.Id).Where(i => (i >> 24) == 0x02)) + { + if (!dats.Portal.TryGet(id, out Setup? setup) || setup is null) + continue; + uint animId = setup.DefaultAnimation?.DataId ?? 0u; + if (animId == 0 + || !dats.Portal.TryGet(animId, out Animation? anim) + || anim is null) + { + continue; + } + + foreach (var frame in anim.PartFrames) + foreach (var hook in frame.Hooks) + { + if (hook is not SetOmegaHook set) + continue; + + // Pure yaw: a non-zero X or Y would tumble the object, and + // the circle-tracing reading of this mechanism would fail. + Assert.Equal(0f, set.Axis.X, 5); + Assert.Equal(0f, set.Axis.Y, 5); + Assert.NotEqual(0f, set.Axis.Z); + + double radius = 0d; + foreach (var placement in setup.PlacementFrames.Values) + foreach (var af in placement.Frames) + radius = Math.Max( + radius, + Math.Sqrt((af.Origin.X * af.Origin.X) + + (af.Origin.Y * af.Origin.Y))); + + found.Add((id, set.Axis.Z, radius)); + } + } + + Assert.NotEmpty(found); + + // Every one of them hangs metres off its own spin axis — that offset IS + // the flight radius. One metre would be a pirouette, not a circuit. + foreach ((uint setupId, _, double radius) in found) + { + Assert.True( + radius > 1d, + $"setup 0x{setupId:X8} spins about an axis only {radius:0.###}m " + + "from its mesh; rotation would not read as flight."); + } + } +} diff --git a/tools/AnimHookScan/AnimHookScan.csproj b/tools/AnimHookScan/AnimHookScan.csproj new file mode 100644 index 00000000..d20c9da7 --- /dev/null +++ b/tools/AnimHookScan/AnimHookScan.csproj @@ -0,0 +1,13 @@ + + + Exe + net10.0 + enable + enable + AnimHookScan + + + + + + diff --git a/tools/AnimHookScan/Program.cs b/tools/AnimHookScan/Program.cs new file mode 100644 index 00000000..6f3ec0db --- /dev/null +++ b/tools/AnimHookScan/Program.cs @@ -0,0 +1,105 @@ +// What does retail actually put in the animations of STATIC ANIMATING objects? +// +// A Static object whose Setup declares a DefaultAnimation or DefaultScript +// joins CPhysics::static_animating_objects (CPhysicsObj::InitDefaults +// @0x00513A7B) and is driven every frame by animate_static_object +// @0x00513DF0. This walks every Setup in portal.dat, follows those defaults, +// and tallies the hook types they carry -- so "which hooks must be honoured +// for this class of object to behave" is read from the data, not assumed. +using AcDream.Content; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Options; +using DatReaderWriter.Types; +using SysEnv = System.Environment; + +string datDir = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine(SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); +using var dats = new DatCollection(datDir, DatAccessType.Read); + +var animHookCounts = new Dictionary(); +var scriptHookCounts = new Dictionary(); +var omegaSetups = new List<(uint Setup, uint Anim)>(); +int setups = 0, withAnim = 0, withScript = 0; + +foreach (uint id in dats.Portal.Tree.Select(e => e.Id).Where(i => (i >> 24) == 0x02)) +{ + if (!dats.Portal.TryGet(id, out var setup) || setup is null) continue; + setups++; + + uint animId = setup.DefaultAnimation?.DataId ?? 0u; + uint scriptId = setup.DefaultScript?.DataId ?? 0u; + if (animId != 0) withAnim++; + if (scriptId != 0) withScript++; + + if (animId != 0 && dats.Portal.TryGet(animId, out var anim) && anim is not null) + { + bool omega = false; + foreach (var frame in anim.PartFrames) + foreach (var hook in frame.Hooks) + { + animHookCounts[hook.HookType] = animHookCounts.GetValueOrDefault(hook.HookType) + 1; + if (hook.HookType == AnimationHookType.SetOmega) omega = true; + } + if (omega) omegaSetups.Add((id, animId)); + } + + if (scriptId != 0 + && dats.Portal.TryGet(scriptId, out var script) && script is not null) + { + foreach (var d in script.ScriptData) + { + AnimationHookType t = d.Hook.HookType; + scriptHookCounts[t] = scriptHookCounts.GetValueOrDefault(t) + 1; + } + } +} + +// Every animation in the dat, not just the Setup defaults: tells us whether +// SetOmega is exclusively a static-animating-scenery mechanism or whether +// creatures use it too (which would widen where the hook must be honoured). +int allAnims = 0, animsWithOmega = 0; +foreach (uint id in dats.Portal.Tree.Select(e => e.Id).Where(i => (i >> 24) == 0x03)) +{ + if (!dats.Portal.TryGet(id, out var a2) || a2 is null) continue; + allAnims++; + bool has = false; + foreach (var fr in a2.PartFrames) + foreach (var h in fr.Hooks) + if (h.HookType == AnimationHookType.SetOmega) has = true; + if (has) animsWithOmega++; +} +Console.WriteLine($"ALL animations={allAnims} containingSetOmega={animsWithOmega}"); + +Console.WriteLine($"setups={setups} withDefaultAnimation={withAnim} withDefaultScript={withScript}"); +Console.WriteLine("\n-- hook types in DefaultAnimation --"); +foreach (var kv in animHookCounts.OrderByDescending(k => k.Value)) + Console.WriteLine($" {kv.Key,-28} {kv.Value}"); +Console.WriteLine("\n-- hook types in DefaultScript --"); +foreach (var kv in scriptHookCounts.OrderByDescending(k => k.Value)) + Console.WriteLine($" {kv.Key,-28} {kv.Value}"); +Console.WriteLine($"\n-- setups whose DefaultAnimation carries SetOmega: {omegaSetups.Count} --"); +foreach (var (s, a) in omegaSetups) +{ + Console.Write($" setup 0x{s:X8} -> anim 0x{a:X8}"); + if (dats.Portal.TryGet(s, out var su) && su is not null) + { + Console.Write($" parts={su.Parts.Count}"); + // How far the authored part frames sit from the setup origin: a mesh + // offset from the point it spins about traces a CIRCLE under grotate. + double maxR = 0; + foreach (var f in su.PlacementFrames.Values) + foreach (var af in f.Frames) + maxR = Math.Max(maxR, Math.Sqrt( + af.Origin.X * af.Origin.X + af.Origin.Y * af.Origin.Y)); + Console.Write($" maxPartRadius={maxR:0.###}m"); + } + if (dats.Portal.TryGet(a, out var an) && an is not null) + foreach (var fr in an.PartFrames) + foreach (var h in fr.Hooks) + if (h is SetOmegaHook so) + Console.Write($" omega=({so.Axis.X:0.###},{so.Axis.Y:0.###},{so.Axis.Z:0.###})"); + Console.WriteLine(); +} diff --git a/tools/AnimHookScan/packages.neutral.lock.json b/tools/AnimHookScan/packages.neutral.lock.json new file mode 100644 index 00000000..2bb494b4 --- /dev/null +++ b/tools/AnimHookScan/packages.neutral.lock.json @@ -0,0 +1,304 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "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" + } + }, + "Autofac": { + "type": "Transitive", + "resolved": "8.4.0", + "contentHash": "XMWHyO6fXTv8rwCfhm6+64mQS6CyL0rve/hWSODsUrVuEGtq1fjxSOlVTBqCRsW6L8K3OQDskJaPB1boVMI2eQ==" + }, + "Chorizite.ACProtocol": { + "type": "Transitive", + "resolved": "1.0.1", + "contentHash": "PVDw/KRu4WPxT+2MzHwOQ9UFqYlOgpIRswSnco/EgHSHnbQyxOqxwiOwSK0Il+cI6dTSXTW34QNmL7iH0lXLKw==", + "dependencies": { + "Chorizite.Common": "1.0.0", + "Medo.PcapRW": "1.2.0", + "Microsoft.Extensions.Logging.Abstractions": "9.0.0", + "System.CodeDom": "9.0.0" + } + }, + "Chorizite.Common": { + "type": "Transitive", + "resolved": "1.0.3", + "contentHash": "KqI0su7UY2diiSQuq11gF/NztqR6orZLr/e5UKTQ91XM8OsZGgBGHmhf2/jopX9VhwpXOTTLIeFBYTBc30cK8w==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "9.0.0" + } + }, + "CommunityToolkit.HighPerformance": { + "type": "Transitive", + "resolved": "8.4.0", + "contentHash": "flxspiBs0G/0GMp7IK2J2ijV9bTG6hEwFc/z6ekHqB6nwRJ4Ry2yLdx+TkbCUYFCl4XhABkAwomeKbT6zM2Zlg==" + }, + "Cyotek.Drawing.BitmapFont": { + "type": "Transitive", + "resolved": "2.0.4", + "contentHash": "iA6WehGVdMUuNbfsQQDq/Bt+mMd/OqHjiMUtKFLIQd/0pyYh4ehT7FEjTxN9/4OXNKQZsp9bAJgltP2nnswUJg==" + }, + "DotNet.Standard.Common": { + "type": "Transitive", + "resolved": "2.0.1", + "contentHash": "zW0m0ytHi43ccbEOTNDa10cDDnT7BAzY1R1Rb1dlhbdkiyglsALjrsyTPSEjbdnTmCOAvAvl4kkbvBLoYhC6dQ==" + }, + "FontStashSharp": { + "type": "Transitive", + "resolved": "1.3.10", + "contentHash": "7JTrihTt3DR8LYbb4L1eZcnbwOUOu/mvY+PJoZ3WVWiKjA6xNUk93GSW3OC/kZBD3iYzrXK8QlmKyYY5Lef/Rg==", + "dependencies": { + "Cyotek.Drawing.BitmapFont": "2.0.4", + "FontStashSharp.Base": "1.1.9", + "FontStashSharp.Rasterizers.StbTrueTypeSharp": "1.1.9", + "StbImageSharp": "2.30.15" + } + }, + "FontStashSharp.Base": { + "type": "Transitive", + "resolved": "1.1.9", + "contentHash": "/AjkOcPNijs8vyNgcCj3FfBJbVWmsSH744hqkhLfBt8qspDz/tEoD+U09my5u9eRBX6zX+RLQ/gdCvwy+ZBOtg==" + }, + "FontStashSharp.Rasterizers.StbTrueTypeSharp": { + "type": "Transitive", + "resolved": "1.1.9", + "contentHash": "yi5iuTERem46uyHC5p+jRi3Jh8dKWzgNWLqcvHciGlyVHD1cWFdERgnxshZU4xWB2hRGnogxaCudCirbMpg4eQ==", + "dependencies": { + "FontStashSharp.Base": "1.1.9", + "StbTrueTypeSharp": "1.26.12" + } + }, + "Medo.PcapRW": { + "type": "Transitive", + "resolved": "1.2.0", + "contentHash": "vgwcHDg60Q9LJfry7twA78pUFio1P4EypI2IIlk+7mEuySsIRInm+Gx2OINDICyocbuyQZdS/zABjbmejAObeg==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "1Am6l4Vpn3/K32daEqZI+FFr96OlZkgwK2LcT3pZ2zWubR5zTPW3/FkO1Rat9kb7oQOa4rxgl9LJHc5tspCWfg==" + }, + "Microsoft.Diagnostics.NETCore.Client": { + "type": "Transitive", + "resolved": "0.2.410101", + "contentHash": "I4hMjlbPcM5R+M4ThD2Zt1z58M8uZnWkDbFLXHntOOAajajEucrw4XYNSaoi5rgoqksgxQ3g388Vof4QzUNwdQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "1.1.0", + "Microsoft.Extensions.Logging": "2.1.1" + } + }, + "Microsoft.Diagnostics.Runtime": { + "type": "Transitive", + "resolved": "3.1.512801", + "contentHash": "0lMUDr2oxNZa28D6NH5BuSQEe5T9tZziIkvkD44YkkCGQXPJqvFjLq5ZQq1hYLl3RjQJrY+hR0jFgap+EWPDTw==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.410101" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "2.1.1", + "contentHash": "LjVKO6P2y52c5ZhTLX/w8zc5H4Y3J/LJsgqTBj49TtFq/hAtVNue/WA0F6/7GMY90xhD7K0MDZ4qpOeWXbLvzg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.1.1" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "2.1.1", + "contentHash": "VfuZJNa0WUshZ/+8BFZAhwFKiKuu/qOUCFntfdLpHj7vcRnsGHqd3G2Hse78DM+pgozczGM63lGPRLmy+uhUOA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.1.1" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "2.1.1", + "contentHash": "fcLCTS03poWE4v9tSNBr3pWn0QwGgAn1vzqHXlXgvqZeOc7LvQNzaWcKRQZTdEc3+YhQKwMsOtm3VKSA2aWQ8w==", + "dependencies": { + "Microsoft.Extensions.Configuration": "2.1.1" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "/hymojfWbE9AlDOa0mczR44m00Jj+T3+HZO0ZnVTI032fVycI0ZbNOVFP6kqZMcXiLSYXzR2ilcwaRi6dzeGyA==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "2.1.1", + "contentHash": "hh+mkOAQDTp6XH80xJt3+wwYVzkbwYQl9XZRCz4Um0JjP/o7N9vHM3rZ6wwwtr+BBe/L6iBO2sz0px6OWBzqZQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "2.1.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.1", + "Microsoft.Extensions.Logging.Abstractions": "2.1.1", + "Microsoft.Extensions.Options": "2.1.1" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "2.1.1", + "contentHash": "V7lXCU78lAbzaulCGFKojcCyG8RTJicEbiBkPJjFqiqXwndEBBIehdXRMWEVU3UtzQ1yDvphiWUL9th6/4gJ7w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.1", + "Microsoft.Extensions.Primitives": "2.1.1" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "2.1.1", + "contentHash": "scJ1GZNIxMmjpENh0UZ8XCQ6vzr/LzeF9WvEA51Ix2OQGAs9WPgPu8ABVUdvpKPLuor/t05gm6menJK3PwqOXg==" + }, + "Namotion.Reflection": { + "type": "Transitive", + "resolved": "3.4.3", + "contentHash": "KLk2gLR9f8scM82EiL+p9TONXXPy9+IAZVMzJOA/Wsa7soZD7UJGG6j0fq0D9ZoVnBRRnSeEC7kShhRo3Olgaw==" + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "NJsonSchema": { + "type": "Transitive", + "resolved": "11.5.1", + "contentHash": "3a7ntoBncSKkLgpIhT3uQ8BiyDzYKOHIzpzNF4o1vtKc+Re4vWxBcDXFDarOWcr/UkxZ8nxRXbbWk05j6bXFzQ==", + "dependencies": { + "NJsonSchema.Annotations": "11.5.1", + "Namotion.Reflection": "3.4.3", + "Newtonsoft.Json": "13.0.3" + } + }, + "NJsonSchema.Annotations": { + "type": "Transitive", + "resolved": "11.5.1", + "contentHash": "xiqZ2DBJM1HuV+EhXgueb5ZUBlWFN3kVfLTKdtpTSxvtyQCO/vit8lqZiUiejnReUMRMIUhtS9m0GbieHZlSow==" + }, + "SixLabors.Fonts": { + "type": "Transitive", + "resolved": "2.1.3", + "contentHash": "ORWbZ5BHrC/LZvo+Y09MnoJq5VUKD85LsYALk+YI7CHFra+m5arCkz00IntDM6SrAiB22bvSdKtKmuCyHOKlqg==" + }, + "SixLabors.ImageSharp.Drawing": { + "type": "Transitive", + "resolved": "2.1.7", + "contentHash": "9KwCo9Fa350cx6ckpsy8NqXQZKwir4RQ8Kj0sdCmJA7wsK9FMyfgC527Sn4l/D6bj2ditSHlhS7dGzcgGszvSQ==", + "dependencies": { + "SixLabors.Fonts": "2.1.3", + "SixLabors.ImageSharp": "3.1.11" + } + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "oTE5IfuMoET8yaZP/vdvy9xO47guAv/rOhe4DODuFBN3ySprcQOlXqO3j+e/H/YpKKR5sglrxRaZ2HYOhNJrqA==" + }, + "ZLibDotNet": { + "type": "Transitive", + "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": { + "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" + } + }, + "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, )", + "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" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[9.0.9, )", + "resolved": "9.0.9", + "contentHash": "FEgpSF+Z9StMvrsSViaybOBwR0f0ZZxDm8xV5cSOFiXN/t+ys+rwAlTd/6yG7Ld1gfppgvLcMasZry3GsI9lGA==", + "dependencies": { + "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.12", + "contentHash": "iAg6zifihXEFS/t7fiHhZBGAdCp3FavsF4i2ZIDp0JfeYeDVzvmlbY1CNhhIKimaIzrzSi5M/NBFcWvZT2rB/A==" + }, + "StbImageSharp": { + "type": "CentralTransitive", + "requested": "[2.30.16, )", + "resolved": "2.30.16", + "contentHash": "qg1i+NHihXVKLKYacGKrauhSQIGL31eBWcTC4Vc7jnGmBFj87LkRuCdXB5aDZisWMRnB6x2mcfwYXQxMWEG/lw==" + }, + "StbTrueTypeSharp": { + "type": "CentralTransitive", + "requested": "[1.26.12, )", + "resolved": "1.26.12", + "contentHash": "hCc6/OsfcPa5VsLECcEU2m78WOshBrKwK42nAodSm9Z5wH68f7n66SoiRLCdGCkDaqbWz2TlX4zYHIjogj1HJA==" + } + } + } +} \ No newline at end of file From 80a3a255940a5ecf227731709e371281f84e85e6 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 05:52:44 +0200 Subject: [PATCH 02/43] =?UTF-8?q?fix(world):=20rotate=20the=20DAT-scenery?= =?UTF-8?q?=20root=20too=20=E2=80=94=20the=20flyers=20actually=20orbit=20n?= =?UTF-8?q?ow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0c552eec wired the SetOmega hook and I called it done. The birds kept flapping in place, and the user's report — "flapping and moving up and down, not orbiting" — is what identified the miss: part animation working, root frozen. BindLiveOwner THROWS on a zero ServerGuid, so owner.Body is only ever assigned for server-spawned entities. Ambient flyers are DAT scenery with no ServerGuid and therefore no PhysicsBody at all. The whole if (owner.Body is { } body) { ... Frame::grotate ... } block — and the omega application I added inside it — silently skipped every object the fix was written for. It applied the mechanism to a branch these objects never take. So the omega now lives on the scheduler's own Owner record rather than on the PhysicsBody, because most of this workset has no body, and the same grotate is applied to entity.Rotation when there is none. That is not a shortcut around the physics owner: for a DAT static the WorldEntity IS the only root retail would be rotating. Verified rather than assumed this time, both halves: - StaticRenderProjectionJournal.SynchronizeActiveAnimatedSources re-projects from the live entity every frame through RenderTransform.FromRoot(entity.Position, entity.Rotation, entity.Scale), so a rotated root reaches the renderer. - Compose builds LOCAL part transforms, so the renderer composes root x part and the offset mesh is carried around its circle. Why it shipped broken: no test exercised a root rotation on the ServerGuid==0 branch, so applying omega body-only passed everything. The new test asserts the rotation on the branch these objects actually take, and fails with the exact production symptom (rotation stays identity) when the branch is disabled. Its sibling pins the other direction — scenery without a SetOmega hook must never acquire a spin. Solution builds clean; 14,475 tests pass on the standard hermetic lane filter, 0 failures. Co-Authored-By: Claude Opus 5 --- .../RetailStaticAnimatingObjectScheduler.cs | 26 +++++- ...tailStaticAnimatingObjectSchedulerTests.cs | 88 +++++++++++++++++++ 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs index 23823d86..b8205292 100644 --- a/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs +++ b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs @@ -32,6 +32,14 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram public required IReadOnlyDictionary?[] SurfaceOverrides; public required bool[] PartAvailable; public PhysicsBody? Body; + + /// + /// Retail CPhysicsObj::m_omegaVector. It lives on the owner + /// rather than on because DAT scenery — which is + /// most of this workset, and every ambient flyer in it — never gets a + /// PhysicsBody at all: BindLiveOwner refuses a zero ServerGuid. + /// + public Vector3 Omega; public AnimationSequencer? PendingProcessHooks; public ulong PendingResidencyVersion; public readonly List PreparedLivePartFrames = new(); @@ -454,6 +462,19 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram } } + else if (owner.Omega != Vector3.Zero) + { + // Same grotate, on the only root these objects have. A DAT + // static has no CPhysicsObj of its own, so the WorldEntity IS + // the frame retail would be rotating — and the render + // projection re-reads entity.Rotation every frame, so the + // parts composed below orbit it. + owner.RootFrameScratch.Origin = owner.Entity.Position; + owner.RootFrameScratch.Orientation = owner.Entity.Rotation; + FrameOps.GRotate(owner.RootFrameScratch, owner.Omega); + owner.Entity.Rotation = owner.RootFrameScratch.Orientation; + } + if (!IsResidentAtVersion(owner, residencyVersion)) { InvalidatePending(owner); @@ -535,9 +556,10 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram private static void ApplyOmegaHooks(Owner owner, IReadOnlyList hooks) { - if (owner.Body is not { } body) + if (!TryResolveOmega(hooks, out Vector3 omega)) return; - if (TryResolveOmega(hooks, out Vector3 omega)) + owner.Omega = omega; + if (owner.Body is { } body) body.Omega = omega; } diff --git a/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs b/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs index 163d1770..1a7378eb 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs @@ -790,6 +790,94 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests Physics: physics); } + /// + /// The ambient flyers: a SetOmega hook must rotate the root of a DAT + /// scenery object, which has no PhysicsBody at all. + /// + /// + /// The first attempt at this fix applied the omega only to + /// owner.Body, and every test passed — because nothing exercised a + /// root rotation on the ServerGuid==0 branch, and BindLiveOwner REFUSES a + /// zero ServerGuid, so DAT scenery never has a body to carry it. Live in + /// the world the birds kept flapping in place. This asserts the rotation on + /// the branch those objects actually take. + /// + [Fact] + public void SetOmegaHook_RotatesTheRootOfDatSceneryWithNoPhysicsBody() + { + var loader = new Loader(); + loader.Add(AnimationId, OmegaAnimation(new Vector3(0f, 0f, -0.027f))); + var scheduler = new RetailStaticAnimatingObjectScheduler( + loader, + (_, sequencer) => sequencer.ConsumePendingHooks(), + (_, _, _) => { }); + WorldEntity entity = MakeEntity(); + Assert.Equal(0u, entity.ServerGuid); // DAT scenery: no body + scheduler.Register(entity, new ScriptActivationInfo( + ScriptId: 0, + PartTransforms: entity.IndexedPartTransforms, + PartAvailability: entity.IndexedPartAvailable, + Setup: MakeSetup(), + DefaultAnimationId: AnimationId, + UsesStaticAnimationWorkset: true)); + + // Retail runs process_hooks AFTER the grotate, so the first frame + // rotates by the still-zero omega and only banks the hook. + scheduler.Tick(1f / 30f); + Assert.Equal(Quaternion.Identity, entity.Rotation); + scheduler.ProcessHooks(); + + // ...and from the next frame on it turns. + scheduler.Tick(1f / 30f); + Assert.NotEqual(Quaternion.Identity, entity.Rotation); + + // Pure yaw: the authored omegas are all Z, so X and Y stay clean. + Assert.Equal(0f, entity.Rotation.X, 5); + Assert.Equal(0f, entity.Rotation.Y, 5); + Assert.NotEqual(0f, entity.Rotation.Z, 5); + + // It keeps turning — this is a continuous circuit, not a one-shot. + Quaternion afterFirst = entity.Rotation; + scheduler.Tick(1f / 30f); + Assert.NotEqual(afterFirst, entity.Rotation); + } + + [Fact] + public void WithoutASetOmegaHookTheRootNeverTurns() + { + var loader = new Loader(); + loader.Add(AnimationId, TwoFrameAnimation()); + var scheduler = new RetailStaticAnimatingObjectScheduler( + loader, + (_, sequencer) => sequencer.ConsumePendingHooks(), + (_, _, _) => { }); + WorldEntity entity = MakeEntity(); + scheduler.Register(entity, new ScriptActivationInfo( + ScriptId: 0, + PartTransforms: entity.IndexedPartTransforms, + PartAvailability: entity.IndexedPartAvailable, + Setup: MakeSetup(), + DefaultAnimationId: AnimationId, + UsesStaticAnimationWorkset: true)); + + for (int i = 0; i < 4; i++) + { + scheduler.Tick(1f / 30f); + scheduler.ProcessHooks(); + } + + // Ordinary animated scenery (a swinging sign, a waterwheel's parts) + // must not acquire a spin it never asked for. + Assert.Equal(Quaternion.Identity, entity.Rotation); + } + + private static Animation OmegaAnimation(Vector3 axis) + { + Animation animation = TwoFrameAnimation(); + animation.PartFrames[0].Hooks.Add(new SetOmegaHook { Axis = axis }); + return animation; + } + private static Animation TwoFrameAnimation() { var animation = new Animation(); From 10304f6dc20880022d3fcf185c04397254b62905 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 06:11:33 +0200 Subject: [PATCH 03/43] fix(chat): announce enchantment expiry; stop double-printing tells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of the four reported chat defects. **Only item spells announced their expiry.** ACE splits the two cases: an enchantment expiring on an ITEM arrives as ordinary server chat ("The spell X on Y has expired.") — which is why those were the only ones showing — while one expiring on the PLAYER arrives as GameEventMagicDispelEnchantment carrying no text at all, because retail's client writes that line itself. ClientMagicSystem::NotifyOfEnchantmentRemoval @0x005686C0 is now ported: the spell's own name plus " has expired.", at LogTextType 7 (Magic), including retail's guards (ids >= 0x8000 skipped, a spell missing from the table prints nothing) and its one special case — spell 0x29A gets " penalty" appended so vitae reads "Vitae penalty has expired." Retail's trailing "\n" is deliberately dropped: its scroll appends raw text, AddText is line-based, and keeping it would print a blank line. **Every tell printed twice.** ACE's GameActionTell replies with a GameMessageSystemChat carrying the finished "You tell X, ..." line (ChatMessageType.OutgoingTell), and we ALSO emitted an optimistic local echo. Retail's own send path, Event_TalkDirectByName @0x00577CF4, has no AddTextToScroll beside it — it just transmits and lets the server's reply print. The local echo is removed, which also makes Tell consistent with Say, which has always relied on the server echo. CH3 had this half-right: it removed the legacy-channel echo for precisely this reason, but kept the Tell echo on the stated grounds that "the server never resends" it. That premise was false. Both test comments asserting it are corrected rather than deleted, since the wrong claim is what made the bug survive review. Solution builds clean; 14,477 tests pass on the standard hermetic lane filter, 0 failures. Co-Authored-By: Claude Opus 5 --- src/AcDream.Core.Net/GameEventWiring.cs | 46 ++++++++- .../Chat/LiveChatCommandRoute.cs | 18 ++-- .../Net/LiveSessionCommandRouterTests.cs | 23 ++--- .../GameEventWiringTests.cs | 94 +++++++++++++++++++ .../Chat/LiveChatCommandRouteTests.cs | 11 ++- 5 files changed, 164 insertions(+), 28 deletions(-) diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index a2295608..13afc2be 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -688,14 +688,54 @@ public static class GameEventWiring registrar.Register(GameEventType.MagicDispelEnchantment, e => { var p = GameEvents.ParseMagicDispelEnchantment(e.Payload.Span); - if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.Layer, p.Value.SpellId); + if (p is null) return; + spellbook.OnEnchantmentRemoved(p.Value.Layer, p.Value.SpellId); + NotifyOfEnchantmentRemoval((uint)p.Value.SpellId); }); registrar.Register(GameEventType.MagicDispelMultipleEnchantments, e => { var entries = GameEvents.ParseMagicLayeredSpellList(e.Payload.Span); - if (entries is not null) - spellbook.OnEnchantmentsRemoved(entries.Select(item => ((uint)item.SpellId, (uint)item.Layer))); + if (entries is null) return; + spellbook.OnEnchantmentsRemoved(entries.Select(item => ((uint)item.SpellId, (uint)item.Layer))); + foreach (var entry in entries) + NotifyOfEnchantmentRemoval((uint)entry.SpellId); }); + + // Retail spell id 0x29A, special-cased by name in + // NotifyOfEnchantmentRemoval so the line reads "Vitae penalty has + // expired." rather than "Vitae has expired." + const uint VitaePenaltySpellId = 0x29Au; + + // ClientMagicSystem::NotifyOfEnchantmentRemoval @ 0x005686C0. The + // server sends NO text for an enchantment leaving the PLAYER — it is + // the client that announces it. (Enchantments leaving an ITEM are + // different: those arrive as ordinary server chat, which is why item + // spells were the only ones showing up.) + void NotifyOfEnchantmentRemoval(uint spellId) + { + if (onInterfaceText is null) + return; + + // Retail's own guards, in order: ids at or above 0x8000 are not + // spells and are skipped outright, and a spell missing from the + // table returns without printing. + if (spellId >= 0x8000 + || !spellbook.TryGetMetadata(spellId, out SpellMetadata meta)) + { + return; + } + + // Vitae reads "Vitae penalty has expired." — retail appends the + // word to the spell's own name for this one id. + string name = spellId == VitaePenaltySpellId + ? meta.Name + " penalty" + : meta.Name; + + // AddTextToScroll(text, 7, 1, 0). Retail's literal carries a + // trailing newline because its scroll appends raw text; AddText is + // line-based, so adding one here would print a blank line. + onInterfaceText($"{name} has expired.", RetailLogTextType.Magic); + } registrar.Register(GameEventType.MagicPurgeEnchantments, _ => spellbook.OnPurgeAll()); registrar.Register(GameEventType.MagicPurgeBadEnchantments, diff --git a/src/AcDream.Runtime/Chat/LiveChatCommandRoute.cs b/src/AcDream.Runtime/Chat/LiveChatCommandRoute.cs index 827f3348..b4128b39 100644 --- a/src/AcDream.Runtime/Chat/LiveChatCommandRoute.cs +++ b/src/AcDream.Runtime/Chat/LiveChatCommandRoute.cs @@ -159,16 +159,14 @@ public sealed class LiveChatCommandRoute case ChatChannelKind.Tell: if (string.IsNullOrEmpty(command.TargetName)) return; - if (!SendIfActive(() => - bindings.SendTell(command.TargetName, command.Text))) - { - return; - } - bindings.Chat.OnSelfSent( - ChatKind.Tell, - command.Text, - logTextType: (uint)RetailLogTextType.SpeechDirectSend, - targetOrChannel: command.TargetName); + // No local echo. The server sends the "You tell X, ..." line + // back itself (ACE GameActionTell -> GameMessageSystemChat with + // ChatMessageType.OutgoingTell 0x04), and retail's own send + // path just transmits: Event_TalkDirectByName @ 0x00577CF4 has + // no AddTextToScroll beside it. Echoing optimistically printed + // the line twice. Say above already relies on the server echo + // for exactly this reason. + SendIfActive(() => bindings.SendTell(command.TargetName, command.Text)); return; } diff --git a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs index ad8b7818..80a656e2 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs @@ -161,17 +161,18 @@ public sealed class LiveSessionCommandRouterTests Assert.Equal([("Friend", "hello")], tells); Assert.Equal([(0x00000800u, "group")], channels); - // CH3 (2026-08-09): Fellowship is one of the ACE server-echoing - // legacy channels (resends with an empty sender) — the router must - // NOT also emit a local optimistic echo, or the line double-prints. - // Only the Tell echo (which the server never resends) survives. - Assert.Collection( - chat.Snapshot(), - entry => - { - Assert.Equal(ChatKind.Tell, entry.Kind); - Assert.Equal("Friend", entry.Sender); - }); + // CH3 (2026-08-09) got the Fellowship half right — ACE resends legacy + // channel lines, so a local echo would double-print — but its stated + // reason for keeping the Tell echo, "which the server never resends", + // was wrong: ACE's GameActionTell replies with a GameMessageSystemChat + // carrying the finished "You tell Friend, ..." line + // (ChatMessageType.OutgoingTell). Tells double-printed in the chat + // window for exactly that reason. Retail's own send path + // @0x00577CF4 has no AddTextToScroll beside it either. + // + // So NEITHER emits a local echo now, and Say has always worked this + // way. + Assert.Empty(chat.Snapshot()); } [Fact] diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs index f093d5d2..8ca7cb69 100644 --- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs @@ -1,5 +1,6 @@ using System; using System.Buffers.Binary; +using System.Collections.Generic; using System.IO; using System.Text; using AcDream.Core.Chat; @@ -1813,6 +1814,99 @@ public sealed class GameEventWiringTests Assert.Empty(rentPayment); } + /// + /// An enchantment leaving the PLAYER is announced by the client, not the + /// server — so nothing printed at all until this was wired. + /// + /// + /// ACE splits the two cases: an enchantment expiring on an ITEM arrives as + /// ordinary server chat ("The spell X on Y has expired."), which is why + /// item spells were the only ones the player ever saw. One expiring on the + /// player arrives as GameEventMagicDispelEnchantment carrying NO text, + /// because retail's client writes the line itself — + /// ClientMagicSystem::NotifyOfEnchantmentRemoval @0x005686C0, printed at + /// LogTextType 7 (Magic). + /// + [Fact] + public void DispelledEnchantment_AnnouncesItsExpiryAtTheRetailTextType() + { + var lines = new List<(string Text, RetailLogTextType Type)>(); + var dispatcher = new GameEventDispatcher(); + GameEventWiring.WireAll( + dispatcher, + new ClientObjectTable(), + new CombatState(), + SpellbookWithNames(), + new ChatLog(), + onInterfaceText: (text, type) => lines.Add((text, type))); + + dispatcher.Dispatch(GameEventEnvelope.TryParse(WrapEnvelope( + GameEventType.MagicDispelEnchantment, + DispelPayload(spellId: 1234, layer: 1)))!.Value); + + Assert.Equal( + ("Fire Protection Self has expired.", RetailLogTextType.Magic), + Assert.Single(lines)); + } + + [Fact] + public void DispelledVitaeReadsAsAPenalty() + { + // Retail appends " penalty" to this one spell's own name, so the line + // reads "Vitae penalty has expired." rather than "Vitae has expired." + var lines = new List<(string Text, RetailLogTextType Type)>(); + var dispatcher = new GameEventDispatcher(); + GameEventWiring.WireAll( + dispatcher, + new ClientObjectTable(), + new CombatState(), + SpellbookWithNames(), + new ChatLog(), + onInterfaceText: (text, type) => lines.Add((text, type))); + + dispatcher.Dispatch(GameEventEnvelope.TryParse(WrapEnvelope( + GameEventType.MagicDispelEnchantment, + DispelPayload(spellId: 0x29A, layer: 1)))!.Value); + + Assert.Equal("Vitae penalty has expired.", Assert.Single(lines).Text); + } + + [Fact] + public void ADispelledSpellMissingFromTheTablePrintsNothing() + { + // Retail returns without printing when InqSpellBase fails, rather than + // announcing a blank name. + var lines = new List<(string Text, RetailLogTextType Type)>(); + var dispatcher = new GameEventDispatcher(); + GameEventWiring.WireAll( + dispatcher, + new ClientObjectTable(), + new CombatState(), + SpellbookWithNames(), + new ChatLog(), + onInterfaceText: (text, type) => lines.Add((text, type))); + + dispatcher.Dispatch(GameEventEnvelope.TryParse(WrapEnvelope( + GameEventType.MagicDispelEnchantment, + DispelPayload(spellId: 4321, layer: 1)))!.Value); + + Assert.Empty(lines); + } + + private static Spellbook SpellbookWithNames() + => new(SpellTable.LoadFromReader(new System.IO.StringReader( + "Spell ID,Name,Flags [Hex]\n" + + "1234,Fire Protection Self,0x4\n" + + "666,Vitae,0x4\n"))); + + private static byte[] DispelPayload(ushort spellId, ushort layer) + { + byte[] payload = new byte[4]; + BinaryPrimitives.WriteUInt16LittleEndian(payload, spellId); + BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(2), layer); + return payload; + } + private static byte[] BuildEnchantment( ushort spellId, ushort layer, diff --git a/tests/AcDream.Runtime.Tests/Chat/LiveChatCommandRouteTests.cs b/tests/AcDream.Runtime.Tests/Chat/LiveChatCommandRouteTests.cs index 04d8fa79..87e0b61c 100644 --- a/tests/AcDream.Runtime.Tests/Chat/LiveChatCommandRouteTests.cs +++ b/tests/AcDream.Runtime.Tests/Chat/LiveChatCommandRouteTests.cs @@ -47,10 +47,13 @@ public sealed class LiveChatCommandRouteTests "client:QueryAge", ], sent); - ChatEntry echo = Assert.Single(communication.Chat.Snapshot()); - Assert.Equal(ChatKind.Tell, echo.Kind); - Assert.Equal("Bob", echo.Sender); - Assert.Equal("secret", echo.Text); + // A tell is SENT and nothing is echoed locally. The server returns the + // "You tell Bob, ..." line itself (ACE GameActionTell -> + // GameMessageSystemChat with ChatMessageType.OutgoingTell), and retail's + // own send path @0x00577CF4 has no AddTextToScroll beside it — so an + // optimistic echo here printed every tell TWICE in the chat window. + // Say has always relied on the server echo for exactly this reason. + Assert.Empty(communication.Chat.Snapshot()); route.Dispose(); route.Publish(new SendServerCommandCmd("@stale")); From 581a61ef0c184b7293e9e12d90a2269d17b836ea Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 06:23:46 +0200 Subject: [PATCH 04/43] feat(chat): the talk-focus menu's Tell-to / Squelch entries actually work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both entries were deliberate no-ops — the code said so — and both showed a static label where retail shows the selected player's NAME. Retail builds them in gmMainChatUI::InitTalkFocusMenu @0x004CDC50 and rebuilds their labels every time the menu opens, substituting the selection through StringInfo::AddVariable_String (@0x004CD91C / @0x004CD982). So they now read "Tell to Dww" / "Squelch (ignore) Dww", rebuilt on open from a live selection provider, and grey out with nothing selected — retail arms the tell slot only for a talkable target (SetTalkFocusEnabled(2, 1) @0x004CD9B0). Picking "Tell to X" aims the chat bar at X. That needed one piece of plumbing: the parser's plain-speech fallthrough returned a null target, so a line typed under a Tell focus was dropped by the router for having no one to send to. Parse/Submit now carry an optional default tell target for exactly that case. "Squelch X" publishes the ALREADY-REGISTERED /squelch verb rather than reimplementing the request — the ModifyCharacterSquelch wire builder (CM_Communication::Event_ModifyCharacterSquelch @0x006A42D0) has been there all along; only the menu path to it was missing. UiMenu gains an OnOpen seam, because a menu whose Items are fixed at Bind can only ever say "Tell to Selected". It fires before _open flips so the rebuilt rows are measured and drawn in the same opening. Solution builds clean; 14,480 tests pass on the standard hermetic lane filter, 0 failures. Co-Authored-By: Claude Opus 5 --- .../UI/Layout/ChatWindowController.cs | 100 +++++++++++++++--- src/AcDream.App/UI/RetailUiRuntime.cs | 12 ++- src/AcDream.App/UI/UiMenu.cs | 14 +++ src/AcDream.Runtime/Chat/ChatCommandRouter.cs | 6 +- src/AcDream.Runtime/Chat/ChatInputParser.cs | 23 +++- .../UI/Layout/ChatWindowControllerTests.cs | 71 +++++++++++++ 6 files changed, 209 insertions(+), 17 deletions(-) diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index 02fc4eb1..e1392ee0 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -137,6 +137,21 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta // ── Channel knowledge (ported from old UiChannelMenu — gmMainChatUI::InitTalkFocusMenu @0x4cdc50) ── + /// + /// The two non-channel entries retail puts at the head of the talk-focus + /// menu. Both act on the CURRENT SELECTION and carry its name in their + /// label — retail substitutes it via StringInfo::AddVariable_String + /// (gmMainChatUI @0x004CD91C / @0x004CD982), which is why the screenshot + /// reads "Tell to +Acdream" rather than "Tell to Selected". + /// + private enum TalkFocusSpecial + { + Squelch, + TellToSelected, + } + + private string? _tellTarget; + private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems = { ("Squelch (ignore)", null), @@ -225,7 +240,8 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta ChatWindowState windowFilters, UiDatFont? datFont, BitmapFont? debugFont, - Func resolve) + Func resolve, + Func? selectedTargetName = null) { ArgumentNullException.ThrowIfNull(windowFilters); @@ -319,7 +335,8 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta // its own authored background sprite (0x0600113A); same reasoning as the // transcript above. c.Input.SpriteResolve = resolve; - c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel); + c.Input.OnSubmit = text => ChatCommandRouter.Submit( + text, vm, busProvider(), c._activeChannel, c._tellTarget); // Campaign CH user-gate round 1 (item G): the imported field's right // edge otherwise holds a FIXED absolute pixel position across a @@ -373,19 +390,78 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta menu.NormalSprite = MenuNormal; menu.PressedSprite = MenuPressed; menu.PopupBgSprite = MenuPopupBg; menu.ItemNormalSprite = MenuItemRow; menu.ItemHighlightSprite = MenuItemSelected; - menu.Items = System.Array.ConvertAll(ChannelItems, - t => new UiMenu.MenuItem(t.Label, (object?)t.Channel)); + string? SelectedName() => selectedTargetName?.Invoke(); + + // Retail rebuilds these two labels around the selected name every + // time the menu opens; ours does the same from the live provider. + void RebuildItems() + { + string? target = SelectedName(); + var items = new List(ChannelItems.Length) + { + new(target is null + ? "Squelch (ignore)" + : $"Squelch (ignore) {target}", + TalkFocusSpecial.Squelch), + new(target is null + ? "Tell to Selected" + : $"Tell to {target}", + TalkFocusSpecial.TellToSelected), + }; + foreach ((string label, ChatChannelKind? channel) in ChannelItems) + { + if (channel is { } ch) + items.Add(new UiMenu.MenuItem(label, ch)); + } + menu.Items = items.ToArray(); + } + + RebuildItems(); menu.Selected = (object?)c._activeChannel; - // Specials (Squelch / Tell-to-Selected, null payload) render WHITE/enabled like - // retail; only the talk-CHANNEL items grey when unavailable. - menu.EnabledProvider = p => p is not ChatChannelKind ch || ChannelAvailable(ch); - menu.ButtonLabelProvider = () => ChannelButtonLabel(c._activeChannel); - // The widget reports the pick; the controller owns Selected. Only a talk-channel - // payload updates the active channel + highlight — the null-payload specials are - // deferred no-ops (see the chat re-drive deferred list) and leave selection intact. + // Talk-CHANNEL items grey when the channel is unavailable; the two + // specials grey when nothing is selected to act on — retail's + // SetTalkFocusEnabled(2, 1) @0x004CD9B0 arms the tell slot only + // once a talkable object is selected. + menu.EnabledProvider = p => p switch + { + ChatChannelKind ch => ChannelAvailable(ch), + TalkFocusSpecial => SelectedName() is not null, + _ => true, + }; + menu.ButtonLabelProvider = () => + c._activeChannel == ChatChannelKind.Tell && c._tellTarget is { } t + ? t + : ChannelButtonLabel(c._activeChannel); + menu.OnOpen = RebuildItems; menu.OnSelect = p => { - if (p is ChatChannelKind ch) { c._activeChannel = ch; menu.Selected = p; } + switch (p) + { + case ChatChannelKind ch: + c._activeChannel = ch; + c._tellTarget = null; + menu.Selected = p; + break; + + // Aims the chat bar at the selected player, so ordinary + // typed text goes to them as a tell. + case TalkFocusSpecial.TellToSelected when SelectedName() is { } name: + c._activeChannel = ChatChannelKind.Tell; + c._tellTarget = name; + menu.Selected = p; + break; + + // The /squelch verb is already registered and carries the + // ModifyCharacterSquelch request + // (CM_Communication::Event_ModifyCharacterSquelch + // @0x006A42D0); the menu entry is another way to reach it, + // not a second implementation. Selection is left alone. + case TalkFocusSpecial.Squelch when SelectedName() is { } squelched: + busProvider().Publish( + new ExecuteClientCommandCmd( + ClientCommandId.Squelch, squelched)); + break; + } }; c.Menu = menu; } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 9e7e9db9..faad50d6 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -1525,7 +1525,17 @@ public sealed class RetailUiRuntime : IDisposable _bindings.Chat.Windows, _bindings.Assets.DefaultFont, _bindings.Assets.DebugFont, - _bindings.Assets.ResolveSprite); + _bindings.Assets.ResolveSprite, + // The talk-focus menu's "Tell to X" / "Squelch (ignore) X" act on + // the current selection and carry its name. + selectedTargetName: () => + { + uint selected = _bindings.Toolbar.Selection.SelectedObjectId ?? 0u; + if (selected == 0u) + return null; + string? name = _bindings.Toolbar.ResolveName(selected); + return string.IsNullOrWhiteSpace(name) ? null : name; + }); 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 9de157ef..7b087934 100644 --- a/src/AcDream.App/UI/UiMenu.cs +++ b/src/AcDream.App/UI/UiMenu.cs @@ -28,6 +28,17 @@ public sealed class UiMenu : UiElement /// Fired with the picked item's payload when a row is chosen. public Action? OnSelect { get; set; } + /// + /// Raised as the popup opens, before the rows are measured or drawn. + /// + /// + /// Retail rebuilds the talk-focus menu's two selection-dependent entries + /// each time it opens (gmMainChatUI @0x004CD8E9), so their labels track + /// whoever is selected right now. A menu whose Items were fixed at Bind + /// could only ever say "Tell to Selected". + /// + public Action? OnOpen { get; set; } + /// Per-payload enabled gate (disabled rows render greyed + are inert). Null ⇒ all enabled. public Func? EnabledProvider { get; set; } @@ -258,6 +269,9 @@ public sealed class UiMenu : UiElement private void SetOpen(bool value) { if (_open == value) return; + // Before _open flips, so a handler that replaces Items is reflected in + // the very first measure/draw of this opening. + if (value) OnOpen?.Invoke(); _open = value; if (FindRoot() is not { } root) return; if (value) root.SetActivePopup(this, () => SetOpen(false)); diff --git a/src/AcDream.Runtime/Chat/ChatCommandRouter.cs b/src/AcDream.Runtime/Chat/ChatCommandRouter.cs index f370f176..8a309367 100644 --- a/src/AcDream.Runtime/Chat/ChatCommandRouter.cs +++ b/src/AcDream.Runtime/Chat/ChatCommandRouter.cs @@ -31,7 +31,8 @@ public static class ChatCommandRouter string? raw, IChatCommandFeedback feedback, ICommandBus bus, - ChatChannelKind defaultChannel) + ChatChannelKind defaultChannel, + string? defaultTellTarget = null) { ArgumentNullException.ThrowIfNull(feedback); ArgumentNullException.ThrowIfNull(bus); @@ -166,7 +167,8 @@ public static class ChatCommandRouter trimmed, defaultChannel, feedback.LastIncomingTellSender, - feedback.LastOutgoingTellTarget); + feedback.LastOutgoingTellTarget, + defaultTellTarget); if (parsed is { } chat) { bus.Publish(new SendChatCmd(chat.Channel, chat.TargetName, chat.Text)); diff --git a/src/AcDream.Runtime/Chat/ChatInputParser.cs b/src/AcDream.Runtime/Chat/ChatInputParser.cs index 6c05892a..846b0f49 100644 --- a/src/AcDream.Runtime/Chat/ChatInputParser.cs +++ b/src/AcDream.Runtime/Chat/ChatInputParser.cs @@ -201,7 +201,8 @@ public static class ChatInputParser string raw, ChatChannelKind defaultChannel, string? lastTellSender, - string? lastOutgoingTellTarget = null) + string? lastOutgoingTellTarget = null, + string? defaultTellTarget = null) { if (string.IsNullOrWhiteSpace(raw)) return null; var trimmed = raw.Trim(); @@ -219,7 +220,12 @@ public static class ChatInputParser string verb = ExtractVerb(substituted); if (IsKnownVerb(verb)) { - return Parse(substituted, defaultChannel, lastTellSender, lastOutgoingTellTarget); + return Parse( + substituted, + defaultChannel, + lastTellSender, + lastOutgoingTellTarget, + defaultTellTarget); } // Unknown @-verb — keep the original @ so ACE recognizes // it server-side. Always emit as Say: ACE's GameActionTalk @@ -281,6 +287,19 @@ public static class ChatInputParser // Plain speech (no recognized verb): emit on the default channel // so the user's text round-trips instead of being silently dropped. + // + // A Tell focus needs a target to go with it. Retail's talk-focus menu + // aims the chat bar at the SELECTED player + // (gmMainChatUI::InitTalkFocusMenu -> SetTalkFocusEnabled(2, 1) + // @0x004CD9B0), and without that name a plain line typed under a Tell + // focus is dropped by the router for having no target. + if (defaultChannel == ChatChannelKind.Tell) + { + return string.IsNullOrEmpty(defaultTellTarget) + ? null + : new ParsedInput(ChatChannelKind.Tell, defaultTellTarget, trimmed); + } + return new ParsedInput(defaultChannel, null, trimmed); } diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs index 071a1fd7..d2c1365c 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs @@ -5,6 +5,7 @@ using AcDream.App.UI.Layout; using AcDream.Core.Chat; using AcDream.UI.Abstractions; using AcDream.UI.Abstractions.Panels.Chat; +using AcDream.Runtime.Chat; namespace AcDream.App.Tests.UI.Layout; @@ -157,6 +158,76 @@ public class ChatWindowControllerTests Assert.NotNull(ctrl); } + // ── Talk-focus specials: "Tell to X" / "Squelch (ignore) X" ───────────── + + /// + /// Both entries act on the CURRENT SELECTION and carry its name, and both + /// used to be deliberate no-ops. + /// + [Fact] + public void TalkFocusSpecials_CarryTheSelectedName_AndActOnIt() + { + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + string? selected = "Dww"; + + ChatWindowController? ctrl = ChatWindowController.Bind( + rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex, + selectedTargetName: () => selected); + Assert.NotNull(ctrl); + UiMenu menu = Assert.IsType(layout.FindElement(0x10000014u)); + + // Retail substitutes the name into both labels + // (StringInfo::AddVariable_String @0x004CD91C / @0x004CD982). + menu.OnOpen!.Invoke(); + Assert.Equal("Squelch (ignore) Dww", menu.Items[0].Label); + Assert.Equal("Tell to Dww", menu.Items[1].Label); + + // Picking "Tell to Dww" aims the chat bar at them, so an ordinary typed + // line is sent as a tell rather than being dropped for want of a target. + menu.OnSelect!.Invoke(menu.Items[1].Payload); + ctrl!.Input.OnSubmit!.Invoke("hello"); + SendChatCmd tell = Assert.IsType(Assert.Single(bus.Published)); + Assert.Equal(ChatChannelKind.Tell, tell.Channel); + Assert.Equal("Dww", tell.TargetName); + Assert.Equal("hello", tell.Text); + + // Squelch reaches the already-registered /squelch verb rather than + // reimplementing the request. + bus.Published.Clear(); + menu.OnSelect.Invoke(menu.Items[0].Payload); + ExecuteClientCommandCmd squelch = + Assert.IsType(Assert.Single(bus.Published)); + Assert.Equal(ClientCommandId.Squelch, squelch.Command); + Assert.Equal("Dww", squelch.Arguments); + } + + [Fact] + public void TalkFocusSpecials_AreInertAndUnnamedWithNothingSelected() + { + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + + ChatWindowController? ctrl = ChatWindowController.Bind( + rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex, + selectedTargetName: () => null); + Assert.NotNull(ctrl); + UiMenu menu = Assert.IsType(layout.FindElement(0x10000014u)); + + menu.OnOpen!.Invoke(); + Assert.Equal("Squelch (ignore)", 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 + // (SetTalkFocusEnabled(2, 1) @0x004CD9B0). + Assert.False(menu.EnabledProvider!(menu.Items[0].Payload)); + Assert.False(menu.EnabledProvider(menu.Items[1].Payload)); + + menu.OnSelect!.Invoke(menu.Items[1].Payload); + menu.OnSelect.Invoke(menu.Items[0].Payload); + Assert.Empty(bus.Published); + } + // ── Test 2: Transcript is placed as a child of the transcript panel ────── [Fact] From 3eb28c57f225445f7f5ef8b4e79d5015f513d9e9 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 06:33:48 +0200 Subject: [PATCH 05/43] fix(chat): the talk-focus button names the focus, not the target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 581a61ef made the chat button display the tell TARGET's name once "Tell to X" was picked. It should read "Tell" — the button names the focus, the same way it reads "Chat", "General" or "Fellow" for the other focuses. Reported against retail. Co-Authored-By: Claude Opus 5 --- src/AcDream.App/UI/Layout/ChatWindowController.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index e1392ee0..da8e21df 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -173,6 +173,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta private static string ChannelButtonLabel(ChatChannelKind k) => k switch { ChatChannelKind.Say => "Chat", + ChatChannelKind.Tell => "Tell", ChatChannelKind.General => "General", ChatChannelKind.Trade => "Trade", ChatChannelKind.Lfg => "LFG", @@ -428,10 +429,9 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta TalkFocusSpecial => SelectedName() is not null, _ => true, }; - menu.ButtonLabelProvider = () => - c._activeChannel == ChatChannelKind.Tell && c._tellTarget is { } t - ? t - : ChannelButtonLabel(c._activeChannel); + // The button names the FOCUS, not the target: retail shows "Tell", + // not the person's name. + menu.ButtonLabelProvider = () => ChannelButtonLabel(c._activeChannel); menu.OnOpen = RebuildItems; menu.OnSelect = p => { From be324c003ccc28e7fc69c3bc7964ba0fda4e2729 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 06:49:59 +0200 Subject: [PATCH 06/43] tools(LayoutDump): dump authored colour arrays; measure retail's tag green MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat deep-dive proved the MECHANISM behind retail's green clickable speaker name — a glyph tag coloured from property 0x1D rather than the line's own 0x1B — but not the colour itself: BuildChatColorLookupTable @0x004F31C0 builds only the 0x1B array, so the value is authored rather than runtime-built and the research correctly returned "UNKNOWN" instead of assuming the green in a screenshot. --colors prints the 0x1B/0x1D arrays of every element in a layout, which measures it out of the installed dats: chat 0x2100006F, transcript 0x10000011 P0x1B [0x00] R=204 G=204 B=204 P0x1D [0x00] R= 0 G=178 B= 0 <- the green Recorded in the research note, including the trap it exposes: the tag colour is per-ELEMENT and authored, while the line colour on that same element comes from the runtime chat table. Filing "tag green" into the LogTextType colour table would put it in the wrong place entirely. Co-Authored-By: Claude Opus 5 --- ...2026-08-21-chat-tagged-name-composition.md | 469 ++++++++++++++++++ tools/LayoutDump/Program.cs | 24 + 2 files changed, 493 insertions(+) create mode 100644 docs/research/2026-08-21-chat-tagged-name-composition.md diff --git a/docs/research/2026-08-21-chat-tagged-name-composition.md b/docs/research/2026-08-21-chat-tagged-name-composition.md new file mode 100644 index 00000000..e0428ba4 --- /dev/null +++ b/docs/research/2026-08-21-chat-tagged-name-composition.md @@ -0,0 +1,469 @@ +# Retail chat: how a tagged, coloured player-name run gets composed + +Research-only. No source files modified. All claims cite `symbol @ 0xADDRESS` +in `docs/research/named-retail/acclient_2013_pseudo_c.txt` (pseudo-C) or +`docs/research/named-retail/acclient.h` (verbatim retail struct defs) unless +otherwise noted. Addresses without an explicit file are in the pseudo-C dump. + +## TL;DR — the mechanism in one paragraph + +Retail does **not** use `StringInfo`'s two-colour-argument mechanism +(`RecvNotice_DisplayFinalStringInfo`'s `arg3`/`arg4`) to colour the player +name differently from the rest of the sentence. `StringInfo` is only a +**localization/variable-substitution template** (string-table id + named +variables, or a literal override) — it has no colour or tag fields at all. +Instead, the sender's name is delivered as a **literal inline markup tag**, +`:>Name<\Tell>`, baked directly into the plain +wide-char sentence *before* it is ever handed to the UI. When that sentence +is appended to the chat log, `UIElement_Text`'s glyph-list builder +(`UIElement_Text::InqGlyphs @ 0x00468ea0`) recognizes the `<...>` markup, +asks `TextTagFactory::MakeTag @ 0x00478480` to parse it into a +`TextTag_IIDString` object (GUID + name), and colours **every individual +`Glyph`** inside the tagged span from `m_curTagFontColor` (font-color +property `0x1D`) instead of the line's `m_curFontColor` (property `0x1B`) — +but **only if the tag's type is the "Tell" enum value `0x10000001`**. Each +`Glyph` also carries a `TextTag*` pointer, which is what makes the run +clickable and lets a click resolve back to the right player. + +## 1. Where the sender name becomes a tagged run + +### 1a. The literal tag text is baked in at message-composition time + +Two adjacent handlers on `ClientCommunicationSystem` build the chat line +for incoming speech, and both embed the markup directly via `sprintf`, +*before* any StringInfo/UI code runs: + +- **Local/overheard speech** — `ClientCommunicationSystem::Handle_Communication__HearSpeech @ 0x005712a0`: + +``` +005714f5 if ((arg4 < 0x50000001 || arg4 > 0x6fffffff)) +005714f5 PStringBase::sprintf(&s_NullBuffer_2, "%s says, \"%s\"\n"); // no tag +005714f5 else +00571511 PStringBase::sprintf(&s_NullBuffer_2, "%s<\Tell> says, \"%s\"\n"); +``` + (full literal recovered from the constant pool: `data_7d0e60 @ 0x007d0e60` + = `"%s<\\Tell> says, \"%s\"\n"`, since Binary + Ninja's inline preview truncates at ~33 chars). + +- **Direct tell** — `ClientCommunicationSystem::Handle_Communication__HearDirectSpeech @ 0x005715a0`, same shape: + +``` +00571880 if ((arg4 < 0x50000001 || arg4 > 0x6fffffff)) +00571880 PStringBase::sprintf(&arg5, "%s tells you, \"%s\"\n"); // no tag +00571880 else +0057189c PStringBase::sprintf(&arg5, "%s<\Tell> tells you, \"%s\"\n"); +``` + (full literal: `data_7d0ec0 @ 0x007d0ec0` = + `"%s<\\Tell> tells you, \"%s\"\n"`). + + `%d` = `arg4`, the speaker's actual object GUID from the wire message. + `%s` (first) = the speaker's display name (repeated once inside the tag + payload, once again as the visible glyph text after the `>`). + +**The `0x50000001..0x6FFFFFFF` GUID-range gate is load-bearing**: only +senders whose object id falls in that range get the clickable/coloured +treatment at all. This is AC1's dynamic-object id range (players and other +non-static weenies); ids outside it (system/NPC broadcast cases handled +elsewhere) fall through to the plain, untagged `"%s says/tells...` format +with no markup and no special colour. + +Group/channel broadcasts go through a **separate** builder, +`ChatRoomTracker::GetChatFormat @ 0x005cd7c0` (called from +`gmCCommunicationSystem::uiChatInterfaceProvider::OnSendToRoom @ 0x0058a590`, +the TurbineChat room-message handler), which always uses `IID:0` in the tag +(no real object id is available/needed there) and prepends the channel +name, e.g. for General chat: + +``` +005cd93a ebx = 0x1b; // LogTextType = General +005cd93f var_1c_14 = &ChannelSystem::General_GlobalChannelName; +005cd85c sprintf(&s_NullBuffer_2, "[%ws] %ws<\\Tell> says, \"%ws\""); +``` +(full literal: `data_7e83e8 @ 0x007e83e8` = +`"[%ws] %ws<\\Tell> says, \"%ws\""`). The function +returns a `ChatDisplayInfo{ m_ltt (LogTextType), m_display (the whole +sprintf'd string), m_doDisplayText }` and the caller passes `m_display` and +`m_ltt` straight into `ClientSystem::AddTextToScroll`. Similar hard-coded +`IID:0` tag formats exist for Fellowship broadcast (`"[Fellowship] %s<\\Tell> says, \""`, +`data_7d0cdc @ 0x007d0cdc`), Co-Vassals (`data_7d0bfc @ 0x007d0bfc`), +Allegiance Broadcast (`data_7d0c30 @ 0x007d0c30`), patron/vassal/follower +tells (`data_7d0d10`, `data_7d0d4c`, `data_7d0ca0`). + +**BN-truncation note**: every one of the `sprintf(..., "...\"" text */, 1); +00563f05 StringInfo::SetLiteralValue(&var_920, &s_NullBuffer_4 /* timestamp string, or empty */, 1); +00563f2b ECM_UI::SendNotice_DisplayFinalStringInfo(arg3 /* LogTextType/colour index */, &var_890, &var_920, arg5 /* window id */); +``` + +This confirms `StringInfo` here is used purely as a **transport wrapper** +around an already-fully-formed literal string — `StringInfo::SetLiteralValue` +sets `m_Override = 1` (literal) so `StringInfo::GetString` later just +returns the wrapped text verbatim; no template/variable substitution +happens for chat lines built this way. (See §4 for why `StringInfo` cannot +itself be the tag carrier.) + +`ChatInterface::RecvNotice_DisplayFinalStringInfo @ 0x004f4640` (the +override registered on `ChatInterface`) receives this notice: + +``` +004f46dc if (StringInfo::IsValid(arg4, 1) != 0) +004f46e9 UIElement_Text::AppendStringInfoWithFont(this->m_chatLog, arg4, 0, 0xc); // arg4 = timestamp StringInfo, colour index 0x0C +004f46fc UIElement_Text::AppendStringInfoWithFont(this->m_chatLog, arg3, 0, arg2); // arg3 = the message StringInfo, colour index arg2 (LogTextType) +``` + +`arg4` (the *second* StringInfo, appended first, at the fixed colour index +`0x0C`) is the **timestamp prefix** (`"HH:MM:SS "`, built a few lines +earlier in `AddTextToScroll` from `PlayerModule::DisplayTimeStamps` + +`wcsftime`), not a channel-name prefix. `arg3` (colour index `arg2` = the +caller-supplied LogTextType) is the **entire rest of the line**, channel +prefix and all — see §3 for why that resolves the "0x0C is grey" question. + +### 1c. `AppendStringInfoWithFont` resolves the literal text and hands it to the glyph parser + +`UIElement_Text::AppendStringInfoWithFont @ 0x00469de0`: + +``` +00469df4 UIElement_Text::SetFontDIDHelper(this, 0x1a, &this->m_curFontObj, arg3); +00469e09 UIElement_Text::SetFontColorHelper(this, 0x1b, &this->m_curFontColor, arg4); // line colour, keyed by LogTextType index arg4 +00469e1a UIElement_Text::SetFontColorHelper(this, 0x1d, &this->m_curTagFontColor, arg4); // tag colour, same index +00469e2a eax_1 = StringInfo::GetString(arg2, &arg3, 0); // resolves literal-override text verbatim +00469e44 UIElement_Text::AddText_Internal(this, m_charbuffer, 3); +``` + +`m_curFontColor` and `m_curTagFontColor` are named fields on +`UIElement_Text` (`docs/research/named-retail/acclient.h:53408,53410`): + +``` +struct __cppobj __declspec(align(8)) UIElement_Text : UIElement_Scrollable, CInputHandler +{ + ... + RGBAColor m_curFontColor; + Font *m_curFontObj; + RGBAColor m_curTagFontColor; + ... +}; +``` + +So **before any markup parsing happens**, the widget primes two colours +for the whole append call — the base line colour and a *separate* tag +colour — both looked up via the exact same LogTextType-indexed mechanism +(`UIElement_Text::SetFontColorHelper @ 0x00466ac0`, which does +`InqProperty(propId) → indexed-array element at [arg4]`). + +### 1d. The glyph-list builder recognizes `<...>` and creates the `TextTag` + +`UIElement_Text::InqGlyphs @ 0x00468ea0` (the routine `AddText_Internal` +uses to turn the resolved wide string into `Glyph` records) scans +char-by-char; on finding `<` it captures through the matching `>` and +calls the tag factory: + +``` +00469021 int32_t eax_16 = TextTagFactory::MakeTag(); // parses the whole "" span +00469084 if (ebx_1 == 0 || tag->m_type != 0x10000001) +00469084 edx_15 = ; // this->m_curFontColor (RGBAColor field order matches struct above) +00469084 else +0046908a edx_15 = ; // this->m_curTagFontColor +004690c5 Glyph::Glyph(&esp_1[9]); // constructs the glyph with the chosen colour + tag pointer +``` + +(Note: this function is heavily register/stack-mangled in Binary Ninja's +output — the raw operand forms above are paraphrased from the literal +`esp`/`ecx` chains in the dump, not verbatim BN text, because the BN +pseudo-C here reads as raw stack-slot arithmetic rather than named field +accesses. The two struct-offset destinations (`+0x6a4`, `+0x6b8`) are +`0x14` bytes apart, matching an `RGBAColor` (16 bytes) + `Font*` (4 bytes) +gap between `m_curFontColor` and `m_curTagFontColor` in the struct dump +above — consistent with, but not a byte-for-byte confirmed alias of, those +two named fields.) + +`Glyph` itself carries **per-character** colour and tag +(`docs/research/named-retail/acclient.h:45330`): + +``` +struct __cppobj Glyph +{ + unsigned __int16 m_data; // character code + int m_width; + int m_height; + RGBAColor m_color; // per-glyph colour — this is what makes the name a different colour from the rest of the line + Font *m_font; + TextTag *m_tag; // non-null only for glyphs inside a ...<\Tag> span — this is what makes it clickable +}; +``` + +**This is the answer to "who creates the tagged run": `TextTagFactory::MakeTag @ 0x00478480`**, +called from `UIElement_Text::InqGlyphs @ 0x00468ea0` while it walks the +resolved plain-text string looking for `<...>` markers. It is a **markup +parser operating on plain text**, not a StringInfo/variable mechanism. + +`TextTagFactory::MakeTag @ 0x00478480` itself: +1. Confirms the captured span starts with `<` and ends with `>`. +2. Splits on the first `:` — the text before it (e.g. `"Tell"`) is looked + up via `EnumMapper::InqEnum(name, 0x18, &m_type)` (a DAT-driven + string→enum table, category `0x18`) to get the numeric tag **type** + (`this->m_type`, e.g. `0x10000001` for `"Tell"`). +3. Splits again on the next `:` — the text between them (e.g. + `"IIDString"`) is looked up the same way to get a small **class** + selector (`var_18`, 1–4), which a `switch` uses to instantiate the + right `TextTag` subclass: + - `1` → `TextTag_DID` + - `2` → `TextTag_IID` + - `3` → `TextTag_IIDEnum` + - `4` → `TextTag_IIDString` (jump table `jump_table_478728 @ 0x00478728`, case `4 @ 0x00478617`) +4. Calls the new tag's virtual `ParseStartTag` on the remaining payload + text (everything after the second `:`, i.e. `":"`). + +## 2. Tag payload — what a click needs to address the right player + +`struct TextTag_IIDString : TextTag { unsigned int m_IID; PStringBase m_string; }` +(`docs/research/named-retail/acclient.h:53960`), with the base class +`struct TextTag : ReferenceCountTemplate<1048576,0> { unsigned int m_type; unsigned int m_format; }` +(`docs/research/named-retail/acclient.h:45358`). + +`TextTag_IIDString::ParseStartTag @ 0x00478910` fills it in: + +``` +00478946 if (PStringBaseIter_Common::FindChar(&iter, ':', 0) != 0) // find the FIRST ':' in "GUID:Name" +00478a02 if (PSUtils::is_uint32(leftPart) != 0) // left of ':' must parse as a uint32 +004788ad this->m_IID = PStringBase::to_uint32(&leftPart); // -> numeric object GUID +00478ae2 PStringBase::operator=(&this->m_string, &rightPart); // -> display name text +``` + +So the payload is **both** the numeric object id **and** the display name +string, not just one or the other. `TextTag_IIDString::BuildStartTagData @ 0x004788e0` +is the inverse (serializes back to `"0x%08X:%ls"`), confirming the same +two-field shape round-trips. + +**Click resolution** (`TextTag_IIDString::HandleClick @ 0x00478840`): + +``` +0047884c ECM_UI::SendNotice_TextTag_IIDStringClick(this->m_type, this->m_IID, &this->m_string); +``` + +which is picked up by `gmMainChatUI::RecvNotice_TextTag_IIDStringClick @ 0x004cce10`: + +``` +004cce1b if (arg2 == 0x10000001 && ChatInterface::IsTextEntryFocused(this) == 0) // arg2 = tag->m_type ("Tell") +004cce2d ChatInterface::StartTell(this, arg4); // arg4 = tag->m_string — starts a /tell using the NAME, not the GUID +``` + +So in the one client-side consumer we traced, the actual action +(`StartTell`) only uses the **name string**, even though the tag also +carries the numeric GUID. The GUID is transmitted through the notice +(`arg3`) but this handler doesn't consume it — it may be used by other, +untraced `RecvNotice_TextTag_IIDStringClick` overrides (several other UI +classes register the same override — see the vtable-slot list in the +pseudo-C dump around `0x0079e580` onward — only `gmMainChatUI`'s and the +base `NoticeHandler::RecvNotice_TextTag_IIDEnumClick` fallback were read in +this pass). **UNKNOWN — needs a scan of every other `RecvNotice_TextTag_IIDStringClick` +override** if a consumer that actually resolves by GUID matters for +acdream's design (e.g. distinguishing two players who changed names, or a +"select in world" action). + +## 3. The `[General]` channel prefix — colour, and resolving the 0x0C puzzle + +**Resolved: the apparent "0x0C is grey" contradiction was a mis-identification +on my part before tracing the code, not a real contradiction.** Index `0x0C` +is not the channel-prefix colour — it's hard-coded in +`ChatInterface::RecvNotice_DisplayFinalStringInfo @ 0x004f4640` (§1b) as the +colour for the **timestamp** StringInfo (`arg4`), which is entirely +separate from the channel-prefixed message text (`arg3`). Confirmed against +`ChatInterface::BuildChatColorLookupTable @ 0x004f31c0` (see below): index +`0x0C`'s colour **is** `colorGrey` — exactly matching the existing project +note. It's grey because it's the timestamp, not because it's "[General]". + +`"[General]"` and the rest of the line (`says, "..."`, including the +embedded name tag's line-colour-before-override) share **one** LogTextType +value for the whole assembled string — set in +`ChatRoomTracker::GetChatFormat @ 0x005cd7c0` (§1a): + +``` +005cd93a ebx = 0x1b; // General +005cd949 ebx = 0x1c; // Trade +005cd95c ebx = 0x1d; // LFG +005cd96d ebx = 0x1e; // Roleplay +005cd97e ebx = 0x12; // Olthoi +005cd9b8 ebx = 0x20; // Society (all variants) +``` + +`ChatInterface::BuildChatColorLookupTable @ 0x004f31c0` builds **one** +LogTextType-indexed colour array (`BaseProperty` at property id `0x1B`, +34 entries, indices `1..0x22`) applied to `this->m_chatLog`. It defaults +every index to `colorGreen @ 0x81c578` and then overrides ~27 of them. +Reading the override sequence address-by-address against the named +`RGBAColor` globals in the constant pool (`0x81c4a8`..`0x81c598`, each +printed with a name, e.g. `class RGBAColor colorGrey = { r=0.824 g=0.824 +b=0.784 a=1 }`) gives this full index→colour table: + +| LogTextType index | Colour name | RGBA | +|---|---|---| +| 2 | colorWhite | (1, 1, 1, 1) | +| 0x0C | colorGrey | (0.824, 0.824, 0.784, 1) — **timestamp**, not General | +| 3, 0xA, 0x13, 0x1F | colorYellow | (1, 1, 0.247, 1) | +| 4, 0xB | colorTan | (0.824, 0.824, 0.392, 1) | +| 5 | colorBrightPurple | (1, 0.498, 1, 1) | +| 6, 0xF, 0x15 | colorDarkRed | (1, 0.247, 0.247, 1) | +| 7, 0x11 | colorLightBlue | (0.247, 0.749, 1, 1) | +| 8, 9 | colorPink | (1, 0.588, 0.588, 1) | +| 0xD | colorCyan | (0.247, 0.863, 0.863, 1) | +| **0xE, 0x1B (General), 0x1C (Trade), 0x1D (LFG), 0x1E (Roleplay), 0x20 (Society)** | **colorBlueGrey** | **(0.706, 0.863, 0.941, 1)** | +| 0x16 | colorLightRed | (0.96, 0.459, 0.447, 1) | +| 0x12 (Olthoi) , 0x21 | colorOrange | (0.933, 0.573, 0.118, 1) | +| 0x1A | colorBrightRed | (1, 0, 0, 1) | +| everything else (1, 0x10, 0x14, 0x17, 0x18, 0x19, 0x22) | colorGreen (default, unoverridden) | (0.5, 1, 0.498, 1) | + +So **General/Trade/LFG/Roleplay/Society chat all render in the same pale +blue-grey** (`colorBlueGrey`) as their base line colour — `"[General]"` +and `says, "..."` are the same colour. This cross-checks cleanly against +the project's existing `claude-memory/reference_retail_chat_colors.md` +(same named constants, same addresses, independently dumped live via cdb +on 2026-06-16): its `colorWhite`→LocalSpeech, `colorBrightPurple`(index +5)→Tell, `colorLightRed`(index 0x16)→Combat, and `colorGrey`(index +0x0C)→"Emote/SoulEmote/fallback" mappings all match the indices found here +exactly. That memory doc's "Channel"→`colorLightBlue` guess (its own text +flags this mapping as an *unverified* nearest-match, "the rare kinds map +to the nearest named color... wasn't traced") is superseded by the exact +trace above: the built-in text channels are `colorBlueGrey`, not +`colorLightBlue` (`colorLightBlue` is indices 7 and 0x11, whose LogTextType +names weren't identified in this pass — **UNKNOWN**, would need the +DAT-driven `LogTextTypeEnumMapper` string table to name every index; see §5). + +**Net effect for the screenshot in the prompt**: `"[General] says, ..."` is +**two** colours, not three — the whole line (brackets, "says,", the +quoted message) in `colorBlueGrey`, and the name span in whatever +`m_curTagFontColor` resolves to (see §5) wherever the tag's type is +`"Tell"` (`0x10000001`). If the user's read genuinely showed three +distinguishable hues, the third one is not explained by anything traced in +this pass — flag as **UNKNOWN, possibly a rendering/outline-colour effect +(`m_curOutlineColor`, also a field on `UIElement_Text`, untraced here) or +a visual misread of anti-aliasing against the grey timestamp prefix.** + +## 4. Is `StringInfo` the tag carrier? + +**No.** Traced its full field set from the constructor/accessor bodies +(`StringInfo::StringInfo @ 0x0042da60`, `::Reset @ 0x0042daf0`, +`::IsValid @ 0x0042cbe0`, `::AddVariable_Int/UInt/Float/String/StringInfo +@ 0x0042dde0-0x0042e7d0`): `m_Override` (0=table-driven / 1=literal / +2=?), `m_stringID`, `m_tableID`, `m_strToken`, `m_LiteralValue`, +`m_strEnglish`, `m_strComment`, and `m_variables` (an +`IntrusiveHashTable` for named-variable +substitution into a localized template). None of these are colour, tag, +or link fields — `StringInfo` is purely a **localization envelope** +(string-table id + substitution variables, or a raw literal override via +`SetLiteralValue`). Tagging is applied **after** the envelope is unwrapped: +`StringInfo::GetString` (called inside `AppendStringInfoWithFont @ +0x00469de0`, §1c) resolves the final plain wide string, and *that* plain +string is what `UIElement_Text::AddText_Internal`/`InqGlyphs` scans for +`<...>` markup. So the two systems are cleanly separated: StringInfo +answers "what text, and in what language", the glyph-list builder answers +"does any of this text contain clickable/differently-coloured spans". + +## 5. Which colour does the tagged name actually use? + +**Structurally proven, exact value UNKNOWN.** §1c/1d prove the mechanism: +`UIElement_Text::SetFontColorHelper(this, 0x1D, &m_curTagFontColor, arg4) @ +0x00469e1a` looks up property `0x1D` through the *identical* +indexed-array-by-LogTextType path as property `0x1B` (line colour) — see +`UIElement_Text::SetFontColorHelper @ 0x00466ac0`, which does +`InqProperty(propId) → array bounds check (arg4 < count) → indexed element +copy`. And `UIElement_Text::InqGlyphs @ 0x00468ea0` proves the *use*: a +glyph gets `m_curTagFontColor` instead of `m_curFontColor` specifically +when its enclosing tag's `m_type == 0x10000001` (the "Tell" tag-name enum +value, resolved via the DAT-driven `EnumMapper` category `0x18` — see §1d +step 2). Tags of any *other* type (e.g. `IIDEnum`-based links used +elsewhere in the client) are still clickable (non-null `Glyph.m_tag`) but +render in the ordinary line colour — the green/special-colour behaviour is +specific to Tell-type name links, not "any markup tag." + +What I could **not** find: a second `BuildXxxColorLookupTable`-style +function that populates property `0x1D`'s array the way +`ChatInterface::BuildChatColorLookupTable @ 0x004f31c0` populates `0x1B` +(that function only ever calls `SetPropertyName(&var_18, 0x1b)` once, and +the whole function body — read start to end — only ever writes to that one +array before the final `this->m_chatLog->vtable->SetProperty(&var_18)`). +Two explanations are consistent with what's traced and neither is +confirmed: +- Property `0x1D` is authored directly on the chat-log `UIElement_Text` + widget via its `LayoutDesc` (a per-widget default, not something + `ChatInterface` code builds at runtime) — plausible since + `SetFontColorHelper`'s `InqProperty` call would find *any* property the + widget inherits, not just ones `BuildChatColorLookupTable` wrote. +- A second, unlocated runtime builder populates it elsewhere. + +**UNKNOWN — needs either**: (a) a `LayoutDesc`/DAT dump of the chat-log +window's property `0x1D` (or its default RGBAColor), or (b) a live cdb +breakpoint on `UIElement_Text::SetFontColorHelper` with `arg2==0x1D` while +a real Tell-tagged line renders, reading `this->m_curTagFontColor` after +the call returns (same toolchain as `claude-memory/reference_retail_chat_colors.md`'s +`x acclient!color*` / `dd` recipe). The user's screenshot reads it as +green, and AC's clickable-name convention is widely remembered as green, +but that is **not** something this pass proved from decomp — flagging it +as inferred-from-screenshot/prior-knowledge, not decomp-verified. + +## Open items / follow-ups + +- §2: only one `RecvNotice_TextTag_IIDStringClick` override + (`gmMainChatUI`) was traced for click behaviour; others exist (vtable + slots reference `NoticeHandler::RecvNotice_TextTag_IIDEnumClick` and + `UIElement::MouseHover` as generic fallbacks — worth a second pass if + acdream needs to replicate hover/tooltip behaviour, not just click). +- §3: LogTextType names for indices 7, 0x11 (colorLightBlue), 0xE + (shares colorBlueGrey with the built-in channels), and the seven + unoverridden default-green indices (1, 0x10, 0x14, 0x17, 0x18, 0x19, + 0x22) are unidentified — the DAT-driven `LogTextTypeEnumMapper` string + table (`struct __cppobj LogTextTypeEnumMapper`, `acclient.h:57333`) + would name them; not pulled in this pass. +- §5: the exact `m_curTagFontColor` RGBA value is unproven from static + decomp alone — see the two follow-up options listed there. +- The `m_format` field on `TextTag` (set from the tag's second colon-split + segment, e.g. `"IIDString"` → the class-selector `1..4`) was read as a + class-shape selector, consistent with the `TextTagFactory::MakeTag` + switch, but its retail name/purpose beyond "which TextTag subclass" was + not otherwise probed. + +## RESOLVED: the tag colour is authored, and it is green + +The research pass above could only prove the *mechanism* for the tag colour +(property `0x1D`, applied per-glyph when a tag is open and its `m_type` is +`0x10000001`), not its value — `ChatInterface::BuildChatColorLookupTable +@0x004F31C0` builds only the ordinary `0x1B` array, so it correctly flagged the +RGBA as UNKNOWN rather than assuming the green seen in a screenshot. + +It is authored in the LayoutDesc, and it measures out of the installed DATs as: + + chat window 0x2100006F, transcript element 0x10000011 + P0x1B (line colour) [0x00] R=204 G=204 B=204 A=255 + P0x1D (tag colour) [0x00] R= 0 G=178 B= 0 A=255 <- the green + +Reproduce with: + + dotnet run --project tools/LayoutDump -c Release -- 0x2100006F --colors + +Two things worth carrying into the port: + +- **The tag colour is per-ELEMENT, not per-LogTextType.** `0x1B` here is a + one-entry array too, so on this element the ordinary colour comes from the + runtime-built chat table while the tag colour comes from the authored + property. A port that files "tag green" into the LogTextType colour table + would be putting it in the wrong place. +- The same `0x1D` green appears on more than one element in this layout, so it + is not unique to the transcript. + +`tools/LayoutDump --colors` was added for this measurement and prints the +`0x1B`/`0x1D` arrays of every element in a layout. diff --git a/tools/LayoutDump/Program.cs b/tools/LayoutDump/Program.cs index c54b98c8..3bdd0615 100644 --- a/tools/LayoutDump/Program.cs +++ b/tools/LayoutDump/Program.cs @@ -22,6 +22,7 @@ if (args.Length == 0) } bool showStates = args.Contains("--states"); +bool showColors = args.Contains("--colors"); uint[] ids = args.Where(a => !a.StartsWith("--")) .Select(a => Convert.ToUInt32(a, a.StartsWith("0x") ? 16 : 10)) .ToArray(); @@ -117,6 +118,29 @@ void Print(ElementInfo e, int depth) + $"parent={(e.HasOriginalParentSize ? $"{e.OriginalParentWidth:0.#}x{e.OriginalParentHeight:0.#}" : "-")} " + $"z={e.ZLevel} order={e.ReadOrder}"); + // Colour-array properties. 0x1B is the ordinary font-colour array and 0x1D + // the TAG font-colour array (UIElement_Text::SetFontColorHelper); both are + // indexed by the caller, so the tagged-name colour is a row in 0x1D rather + // than anything the runtime builds. + if (showColors) + { + foreach (UiStateInfo state in e.States.Values) + { + foreach (uint prop in new[] { 0x1Bu, 0x1Du }) + { + if (!state.Properties.TryGetValue(prop, out UiPropertyValue? v) || v is null) + continue; + Console.WriteLine($"{pad} P0x{prop:X2} ({v.ArrayValue.Count} entries):"); + for (int i = 0; i < v.ArrayValue.Count; i++) + { + UiColorValue c = v.ArrayValue[i].ColorValue; + Console.WriteLine( + $"{pad} [0x{i:X2}] R={c.Red,3} G={c.Green,3} B={c.Blue,3} A={c.Alpha,3}"); + } + } + } + } + if (showStates && e.States.Count != 0) { string names = string.Join(", ", e.States From 663129c340eba30a696fdbaf8ce20344599f9127 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 06:52:05 +0200 Subject: [PATCH 07/43] =?UTF-8?q?docs:=20Campaign=20CT=20=E2=80=94=20chat?= =?UTF-8?q?=20text=20tags,=20researched=20and=20planned?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six parallel research lanes on retail's chat text and window behaviour, plus a plan. The headline: the green clickable speaker name is not a chat feature and not a colour, it is a missing capability in the TEXT stack. Retail's client sprintfs literal tag markup into the chat line, and the text element parses the brackets while appending, attaching a ref-counted tag PER GLYPH. A tagged run is emergent: adjacent glyphs whose tag pointers are equal. A glyph takes the tag colour (property 0x1D) only when a tag is open and its type is 0x10000001; otherwise the ordinary line colour (0x1B). The colour itself was the one thing the decomp could not settle — it is authored, not runtime-built — so it was MEASURED out of the installed dats rather than assumed from a screenshot: P0x1D = RGB(0,178,0). That also exposed a trap: the tag colour is per-ELEMENT and authored while the line colour on the same element comes from the runtime chat table, so filing "tag green" into the LogTextType table would put it in the wrong place. Our own audit found the gap is narrower than feared. UiText ALREADY draws multi-coloured runs (the character stat panel uses it); the path is just gated to single-line elements. The draw path needs no renderer work, and HitChar already resolves a click to line+column. The real blocker is that sender identity is destroyed before it reaches the renderer: ChatEntry carries Sender/SenderGuid the whole way, and ChatVM.RecentLinesDetailed drops both. Two findings beyond the original question. Retail BOUNDS its transcript (10,000 chars, trimmed to ~7,500 at a newline) and splits auto-scroll from an unread indicator by sampling "was at bottom" before the line lands — a naive port auto-scrolls forever and leaks for the life of a session. And the chat-UI audit turned up an untracked bug: Escape in the chat input does nothing at all, because UiField has no Escape case and a focused field also suppresses the input dispatcher's fallback. Every lane was instructed to write "UNKNOWN — needs X" rather than guess, and they did; the carried unknowns are listed in the plan rather than papered over. Seven slices proposed, nothing implemented yet. Co-Authored-By: Claude Opus 5 --- .../2026-08-21-chat-text-tag-campaign.md | 126 +++ .../2026-08-21-acdream-chat-ui-audit.md | 188 ++++ .../2026-08-21-acdream-text-stack-audit.md | 376 ++++++++ .../2026-08-21-chat-tag-click-dispatch.md | 543 +++++++++++ .../research/2026-08-21-chat-texttag-model.md | 849 ++++++++++++++++++ .../2026-08-21-retail-chat-window-ui.md | 650 ++++++++++++++ 6 files changed, 2732 insertions(+) create mode 100644 docs/plans/2026-08-21-chat-text-tag-campaign.md create mode 100644 docs/research/2026-08-21-acdream-chat-ui-audit.md create mode 100644 docs/research/2026-08-21-acdream-text-stack-audit.md create mode 100644 docs/research/2026-08-21-chat-tag-click-dispatch.md create mode 100644 docs/research/2026-08-21-chat-texttag-model.md create mode 100644 docs/research/2026-08-21-retail-chat-window-ui.md diff --git a/docs/plans/2026-08-21-chat-text-tag-campaign.md b/docs/plans/2026-08-21-chat-text-tag-campaign.md new file mode 100644 index 00000000..f80459dd --- /dev/null +++ b/docs/plans/2026-08-21-chat-text-tag-campaign.md @@ -0,0 +1,126 @@ +# Campaign CT — chat text tags and chat-window parity + +**Status:** PROPOSED (2026-08-21). Not started. + +Retail renders a speaker's name inside a chat line in green, and clicking it +opens a tell to that person. acdream renders flat, uniformly coloured, inert +lines. Six parallel research lanes established why, and the answer is not a +chat bug — it is a **missing capability in the text stack**. + +Research notes (all 2026-08-21): `chat-texttag-model.md`, +`chat-tagged-name-composition.md`, `chat-tag-click-dispatch.md`, +`retail-chat-window-ui.md`, `acdream-text-stack-audit.md`, +`acdream-chat-ui-audit.md`. + +## The mechanism, proven + +1. The CLIENT composes the markup. `Handle_Communication__HearSpeech + @0x005712A0` / `HearDirectSpeech @0x005715A0` sprintf a literal tag into the + plain chat line, of the shape + `{name}<\Tell> says, "{text}"` — the closing + marker is a literal backslash. Only senders whose GUID is in AC1's player + range `0x50000001..0x6FFFFFFF` are tagged at all. + +2. `UIElement_Text::InqGlyphs @0x00468EA0` recognises the brackets while + appending and calls `TextTagFactory::MakeTag @0x00478480`. Any bracketed + text that fails to parse closes the open tag — `MakeTag` requires a `:` to + succeed, which is exactly what makes the bare closer a closer. + +3. Tags attach **per glyph**. There is no run or span object anywhere: a + "tagged run" is emergent, re-derived by walking neighbouring glyphs whose + `m_tag` pointers are equal. + +4. Colour: a glyph takes the TAG colour (property `0x1D`) only when a tag is + open AND its `m_type == 0x10000001`; otherwise the ordinary line colour + (`0x1B`). Both are DAT-authored arrays on the element. + +5. **Measured** out of the installed dats (`LayoutDump --colors`), chat + `0x2100006F`, transcript `0x10000011`: + + P0x1B (line) [0x00] R=204 G=204 B=204 + P0x1D (tag) [0x00] R= 0 G=178 B= 0 <- the green + +6. Click: `UIElement_Text::MouseUp @0x004694F0` → `DeterminePositionFromXY + @0x004688F0` (xy → glyph index) → `GlyphList::InqGlyph @0x00473430` → a + virtual `HandleClick` at tag-vtable `+0x14` → `SendNotice_TextTag_*Click` → + `gmMainChatUI::RecvNotice_TextTag_IIDStringClick @0x004CCE10` → + `ChatInterface::StartTell @0x004F41F0`, which writes `"@tell {Name}, "` into + the entry, takes keyboard focus, and shows the entry bar. + +Two facts that shape the port: + +- **The tag colour is per-ELEMENT and authored**, while the line colour on the + same element comes from the runtime chat table. Filing "tag green" into the + LogTextType colour table would put it in the wrong place. +- **Clicking a name always opens a TELL**, everywhere. Fellowship, allegiance, + patron/vassal and named-channel lines all embed the same markup. Dispatch is + generic over four tag shapes, but only `IIDString` has a listener in this + build. + +Retail applies no hover effect to a tag, and the colour is a static per-glyph +bake at append time — not a render-time lookup. + +## What we already have + +The audit found more than expected. `UiText` **already** has a +`TextRun`/`RunsProvider` path that draws several differently-coloured runs on +one line (used today by the character stat panel) — it is simply gated to +`OneLine == true`, and the chat transcript is multi-line. The draw path needs +no renderer work at all: it already accepts an arbitrary pen X and can measure +substrings. `UiText.HitChar` already resolves a click to (line, column). + +So the gap is narrower than "build a text tag system": + +- the multi-line path cannot carry runs, and +- sender identity is **destroyed before it reaches the renderer**: `ChatEntry` + keeps `Sender`/`SenderGuid` all the way through `ChatLog`, and + `ChatVM.RecentLinesDetailed()` builds a `FormattedLine` that drops both. + +## Slices + +**CT1 — runs on the multi-line text path.** Extend the existing `TextRun` +model to multi-line elements; per-line run lists; additive, so `Line` keeps +working and the ~50 files using it are untouched. No behaviour change. + +**CT2 — markup parse.** Parse the tag markup into runs carrying a tag payload, +including retail's rule that an unparseable bracket closes the open tag. Pure +and unit-testable, no UI. + +**CT3 — stop flattening, and emit the markup.** Carry sender name + guid +through `ChatVM` into spans, and compose retail's markup in the speech handlers +behind the player-GUID-range gate. This is the slice that makes the name a +distinct run at all. + +**CT4 — tag colour.** Read the authored `0x1D` array per element and apply it +when a tag is open and its type matches. Uses CT1's runs. + +**CT5 — click to tell.** Sub-line hit-testing (`HitChar` → run → tag) and +`StartTell` behaviour: write `"@tell {Name}, "`, focus the entry, show the +entry bar. Dispatch keyed generically by tag type, with only `IIDString` wired. + +**CT6 — chat window behaviours.** Bound the transcript (10,000 chars, trim to +~7,500 preferring a newline boundary); split auto-scroll from the unread +indicator (`0x1000048C` — retail samples "was at bottom" BEFORE the line +lands); the option-gated timestamp prefix. Unbounded scrollback is also a slow +leak for the life of a session, not only a fidelity gap. + +**CT7 — tail.** The Escape-in-chat-input no-op the audit found (no `Escape` +case in `UiField`, and a focused field also suppresses the input dispatcher's +fallback, so nothing happens at all); delete the dead ImGui-era `ChatPanel`; +reconcile the stale digest/ISSUES rows (#358, #362, #363, #367, #372, #379, +#380, #382 are DONE in code but still listed open). + +## Deliberately NOT in scope + +Item links and the other three tag shapes (`DID`, `IID`, `IIDEnum`). They have +no listener in the retail build we target, so porting them would be inventing +behaviour. CT5's dispatch is generic, so they cost nothing to add later. + +## Known-unknowns carried + +- The symbolic name behind tag type `0x10000001` (only "Tell" is confirmed); + the full roster lives in the DAT `EnumMapper` category `0x18`. +- Whether the retail transcript supports text selection distinctly from the + entry field. +- The chat log file's path and rotation (`ClientSystem::s_pLogFile`) — retail + writes a plain-text session log a port would miss entirely. diff --git a/docs/research/2026-08-21-acdream-chat-ui-audit.md b/docs/research/2026-08-21-acdream-chat-ui-audit.md new file mode 100644 index 00000000..45879e71 --- /dev/null +++ b/docs/research/2026-08-21-acdream-chat-ui-audit.md @@ -0,0 +1,188 @@ +# acdream chat UI audit — window controllers, input bar, menus, filters, window management + +Scope: acdream's own chat UI implementation (window controllers, view models, +input bar, menus, filters, window management). Explicitly OUT of scope per +task boundary: retail's glyph tag system, tag click dispatch, and the text +rendering stack (owned by a parallel audit) — not covered here beyond +incidental mentions needed to explain routing. + +All claims below are cited `file.cs:line` against the current worktree +(`C:\Users\erikn\source\repos\acdream\.claude\worktrees\objective-leavitt-0cbd10`). +This document supersedes nothing in `claude-memory/project_chat_digest.md`; +it verifies and extends it against the current code as of 2026-08-21. + +--- + +## 1. Inventory — what exists and what each surface owns + +| Surface | File | Owns | +|---|---|---| +| Main chat window | `src/AcDream.App/UI/Layout/ChatWindowController.cs` | Binds LayoutDesc `0x2100006F` (retail `gmMainChatUI`/`ChatInterface`, `m_eWindowID==8`). Transcript (`UiText`), input (`UiField`), scrollbar, talk-focus channel menu (`UiMenu`), Send button, max/min toggle, the four floating-window indicator LEDs (mirror + click), the 8 resize-grip locked/live cosmetic swap seed. | +| Floating chat windows ×4 | `src/AcDream.App/UI/Layout/FloatingChatWindowController.cs` | Binds LayoutDesc `0x2100005B` (retail `gmFloatyChatUI`, `m_eWindowID` 2-5) four times, one `FloatingChatWindowController` instance per `WindowId` 1-4. Transcript, input (channel hardcoded to Say), scrollbar, Send button, hardcoded `"Chat {windowId}"` title, Close button. | +| Chat view-model | `src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs` | Formats `ChatLog` entries to display lines (`RecentLines`/`RecentLinesDetailed`), owns `/framerate`/`/loc` client-side output, the `ShowSystemMessage`/`ShowInterfaceText` (0x1A→SpewBox) split, and `ChatCommandTargetState` (last-tell-sender/target) via `_commandTargets`. | +| Chat submit pipeline | `src/AcDream.Runtime/Chat/ChatCommandRouter.cs` | The one `Submit` chokepoint both `ChatWindowController.Bind`'s `Input.OnSubmit` (`ChatWindowController.cs:339-340`) and `FloatingChatWindowController.Bind`'s `Input.OnSubmit` (`FloatingChatWindowController.cs:157`) call. | +| Chat parsing / catalog | `src/AcDream.Runtime/Chat/ChatInputParser.cs`, `RetailClientCommandCatalog.cs`, `RetailCommandHelpTable.cs`, `RetailChannelTagTable.cs` | Verb resolution, 152-verb registry, `/help` text. | +| Per-window filter/open state | `src/AcDream.Core/Chat/ChatWindowState.cs` | The ONE canonical `ChatWindowState` (5 windows: id 0 main + 1-4 floaty) both controllers read live — filters, open/closed, `ShouldDisplay`/`TypeIsActive` (retail's `ChatInterface::TypeIsActive`/`RecvNotice_DisplayFinalStringInfo`). | +| Chat colors | `src/AcDream.UI.Abstractions/Panels/Chat/RetailChatColorTable.cs` | 34-value `RetailLogTextType`→RGBA table, hard-coded (matches retail; no user config — confirmed still true, see §2). | +| Input widget | `src/AcDream.App/UI/UiField.cs` | Generic editable-field widget; the chat entry is one instance of this, built by `DatWidgetFactory.BuildText` for the DAT's Type-12 Editable element. | +| Talk-focus / dropdown menu | `src/AcDream.App/UI/UiMenu.cs` | Generic dropdown popup widget; the chat channel selector is one instance. | +| Chat-tab Settings surface | `src/AcDream.App/UI/Layout/ChatOptionsPageController.cs` (+ `ChatOptionsDatDefaults.cs`) | Options panel → Chat tab: two opacity sliders + 5 per-window 13-row text-type filter blocks, all live-writing `ChatWindowState`/`RetailWindowOpacityController`. | +| Persistence | `src/AcDream.App/UI/RetailUiRuntime.cs` (`SaveChatWindowFilters`, `SaveChatOpacity`, load path ~`RetailUiRuntime.cs:1516-1518`) + generic `RetailWindowLayoutPersistence` | Filters (all 5 windows) + opacity persist to local `settings.json`; window geometry/open-state persist "for free" once registered under `WindowNames.Chat`/`ChatWindow1..4` (`src/AcDream.App/UI/WindowNames.cs:19-23`). | +| Dead/legacy surface | `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs` | An `IPanel` (ImGui-era D.2a stack) chat panel. **Never instantiated in `src/`** — see §6. | + +**Not in this lane** (owned elsewhere per the task boundary): glyph tag +colouring/click dispatch, `UiText` rendering internals, SpewBox glyph +drawing. Where the input bar or router hands text to the SpewBox +(`ChatVM.ShowInterfaceText`, `ChatVM.cs:177-183`) that routing decision is +in-lane; the SpewBox's own rendering is not. + +--- + +## 2. Window management + +**Multiple windows.** Five windows total: 1 main (always open, `ChatWindowState.MainWindowId=0`, `ChatWindowState.cs:68,169-175`) + 4 floating (independently open/closed, `ChatWindowState.cs:69-70,178-206`). Confirmed working end-to-end: `ChatWindowController.SetIndicatorOpen`/`BindIndicatorClicks` (`ChatWindowController.cs:671-700`) mirror + drive floating-window visibility from the main window's 4 indicator LEDs, and `RetailUiRuntime.ToggleFloatingChatWindow` (`RetailUiRuntime.cs:1142-1143`) is the Alt+1..4 keybind's landing point (`KeyBindings.cs:218-221`). + +**Filters.** Per-window 64-bit `LogTextType` bitmask filter (`ChatWindowState.GetFilter`/`SetFilter`/`ShouldDisplay`, `ChatWindowState.cs:145-167,230-235`), fully live: both controllers read it every transcript rebuild (`ChatWindowController.cs:742,776-777`; `FloatingChatWindowController.cs:261,278-279`), and it's user-editable through the Options→Chat tab (`ChatOptionsPageController.cs:181-193` five `FilterBlockSpec` rows, `:552-598` `BuildFilterBlock`). This is MORE complete than the chat digest implied — the digest's "Main's filter IS user-settable" note (digest line 118-119) is confirmed and the floaty filters are settable through the same UI, not just the main window. + +**Move/resize.** The 8 authored resize grips (`ChatWindowController.cs:38-43` doc) and the drag/move title-strip import generically via `DatWidgetFactory`/`UiResizeGrip` — no per-controller binding code needed (confirmed by the class doc; no resize-specific code exists in either controller beyond `AttachWindow`/`ToggleMaximize`). + +**Maximize/restore (main window only).** `ChatWindowController.ToggleMaximize` (`ChatWindowController.cs:537-580`) is a faithful port of `gmMainChatUI::HandleMaximizeButton @0x004CCE50` (save/restore Y+height, half-parent expansion, up/down growth choice, DAT-constraint clamping) plus `CaptureWindowState`/`RestoreWindowState` (`ChatWindowController.cs:702-715`) for session persistence. **Floating windows have no maximize** — matches retail (no max/min button authored on `0x2100005B`; `FloatingChatWindowController.cs` has no `MaxMinId`/`ToggleMaximize` equivalent, and this is correct, not a gap). + +**Close (floating windows only).** `FloatingChatWindowController.Bind`'s Close-button wiring (`FloatingChatWindowController.cs:211-214`) calls `c.WindowHandle?.Hide()` — a straight port of `gmFloatyChatUI::ListenToElementMessage @0x004CE330`. **Main window has no close button** (matches retail; `ChatWindowState.SetOpen`/`Toggle` are explicit no-ops for window 0, `ChatWindowState.cs:177-188,195-206`). + +**Opacity.** Two linked sliders (Default/Active), ported with retail's `DualHash` drag-the-other-value link (`ChatOptionsPageController.cs:344-372` doc, `:412-451`), scoped to exactly the 5 chat windows per issue #379 (see §5 — DONE). Batched persistence (`RetailUiRuntime.SaveChatOpacity`, `RetailUiRuntime.cs:1201-1210`) flushes once per discrete edit, not per drag tick. + +**Persistence.** Filters for all 5 windows + both opacity values write to local `settings.json` on every live edit (`RetailUiRuntime.cs:1169-1210`) and reload at startup (`RetailUiRuntime.cs:1516-1518` for the main window's filter; the floaty load leg is the analogous call in `MountFloatingChatWindows`, cited by that same comment). Window geometry/open-state ride the generic `RetailWindowLayoutPersistence` path since all 5 windows are registered under distinct `WindowNames` entries (`WindowNames.cs:19-23`). **No gap found here** — window management persistence is comprehensive. + +**Known, already-registered divergences (not new findings, listed for completeness):** +- AP-187/AP-189 (`docs/architecture/retail-divergence-register.md`): floaty filters are local-`settings.json`-only, no `0x1000008C` server-side wire sync between installs; and the chat log's shared `500`-entry ring buffer / `200`-entry display tail (`ChatLog.cs:21-22,447-448`; `InteractionRetainedUiComposition.cs:465`) gives every window a shallower **effective per-window** scrollback than retail's own **per-window** 10,000-line log — a low-traffic window's messages can be evicted from the shared tail by unrelated high-traffic windows' spam before that window's own filter ever sees them. Behaviorally the accumulate-while-closed and independent-per-window-scroll-position mechanics are correctly reproduced; only the depth differs. +- AP-188/#369 (OPEN): floating windows hardcode Send-channel to Say (`FloatingChatWindowController.cs:157`) because the floaty LayoutDesc authors no talk-focus menu; whether retail's floaties actually share the main window's last-picked channel is UNRESEARCHED (see §5). +- AP-190/#379 (#379 DONE, AP-190 partially retired): opacity scope-to-chat-only is fixed; the digest's "we snap, retail eases 5%-of-range per tick" easing-curve residual is unverified as still true today — UNKNOWN, needs re-check against `RetailWindowOpacityController` if picked up. + +--- + +## 3. Input bar — exactly what `UiField` supports + +Source: `src/AcDream.App/UI/UiField.cs`, wired per-window at `ChatWindowController.cs:329-372` (main) and `FloatingChatWindowController.cs:151-175` (floaty). Both controllers configure the SAME widget class with near-identical wiring (the floaty path lacks the talk-focus channel, per §2/#369). + +**Supported:** +- **Typing / editing:** `InsertChar`, `Backspace`, `DeleteForward`, held-key auto-repeat for Backspace/Delete/Left/Right (`UiField.cs:159-189,683-700`, 0.40s delay / 25/s repeat). +- **Caret movement:** Left/Right (`MoveCaret`), Home/End (`MoveCaretTo`), all Shift-extendable when `Selectable` (`UiField.cs:791-811`). No Ctrl+Left/Right word-jump, no Ctrl+Backspace delete-word. +- **Selection:** mouse click+drag (`MouseDown`/`MouseMove`, `UiField.cs:749-763`), Shift+arrow, Ctrl+A select-all — **all three gated behind `Selectable`** (`UiField.cs:781,789`), which is DAT-authored property `0x27` on element `0x10000016`. Confirmed live-DAT-true for the chat input specifically: `ChatLayoutConformanceTests.ChatFixture_BuildsSelectableTranscriptAndEditableInputInPlace` (`tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs:220-228`) asserts `input.Selectable == true` after `ChatWindowController.Bind`, and neither controller sets it explicitly (`ChatWindowController.cs`/`FloatingChatWindowController.cs` grep clean for `Input.Selectable`) — so this is the real DAT default, not an accidental pass. **Not a gap.** +- **Clipboard:** Ctrl+C/Ctrl+X gated behind `Selectable` (same as above); Ctrl+V (paste) is **not** gated and always works when `Editable` (`UiField.cs:265-303,784`). Paste strips control characters and normalizes CR/LF for multi-line fields; the chat input is one-line so paste collapses to a single stripped line. +- **History:** 100-entry cap (`UiField.cs:326`, sentinel via `_historyIndex=-1`), Up/Down browse (`HistoryPrev`/`HistoryNext`, `UiField.cs:330-350,809-810`) — a faithful port per the class doc's citation of `ChatInterface::ProcessCommand @0x4f5100`. +- **Submit:** Enter/KeypadEnter (`UiField.cs:792-802`) calls `Submit()` → `OnSubmit` → clears (`ClearOnSubmit`, default true) → pushes history (`RecordHistory`, default true) → releases keyboard focus (`FindRoot()?.SetKeyboardFocus(null)`) — "exit write mode after sending," matching retail's read-mode/write-mode chat behavior. +- **Max length:** DAT-authored via property `0x1E` → `UiField.MaxCharacters` (`DatWidgetFactory.cs:788-789`); default `0xFFFF` if the DAT doesn't author one. Not hardcoded in the controller — correctly deferred to the imported layout. +- **Focus entry (keyboard):** `UiRoot.OnKeyDown` has a special case — when nothing is focused, Tab or Enter/KeypadEnter focuses `DefaultTextInput` (`UiRoot.cs:1059-1071`), which `RetailUiRuntime.cs:1587` sets to the bound chat `Input`. This is the actual, working mechanism for "press Enter/Tab to start typing" — it runs entirely inside `UiRoot`, independent of the `InputDispatcher`/`InputAction` system. + +**Missing / gaps found:** +1. **Escape does nothing while the chat input is focused.** `UiField.OnEvent`'s `KeyDown` switch (`UiField.cs:790-811`) has no `Key.Escape` case, so it falls through to `return false;` (implicit end-of-block after the switch, `UiField.cs:812`). Because `KeyboardFocus.IsEditControl` is true for a focused `UiField` (`UiField.cs:149`), `UiRoot.OnKeyDown`'s modal/root fallback branch (`UiRoot.cs:1083-1089`) is skipped entirely, and the event falls to `WorldKeyFallThrough` (`UiRoot.cs:1091`) — **an event nobody subscribes to in production** (grep for `WorldKeyFallThrough +=` across `src/` finds only the class's own declaration and a `README.md` code sample, `src/AcDream.App/UI/UiHost.cs:19`, `src/AcDream.App/UI/UiRoot.cs:408`). Separately, `InputDispatcher`'s own action-routing is gated off entirely whenever a widget holds keyboard focus (`_mouse.WantCaptureKeyboard` → `SilkMouseSource.cs:193` → `Root.WantsKeyboard` → `KeyboardFocus is not null`, `UiRoot.cs:196`), so `GameplayInputCommandController.HandleEscape` (`GameplayInputCommandController.cs:238-248`, cancel target mode / exit fly mode / close window) never fires either. **Net effect: pressing Escape while the chat box has focus is a complete no-op in acdream today** — no clear, no defocus, no fallback to a game hotkey. This is not tracked in `docs/ISSUES.md` under any existing chat issue. +2. **No autocomplete / tab-completion** of player names, channel tags, or command verbs. Confirmed by exhaustive grep (`autocomplete|tabcomplete|namecomplet` across `src/`) — zero hits. `ChatCommandRouter.Submit` (`ChatCommandRouter.cs:30-179`) is pure parse-and-dispatch with no partial-match suggestion path. Whether retail AC's chat box ever had tab-completion is UNKNOWN — not established either way in this pass; flagging the absence, not asserting it's a regression. +3. **`@title` is a documented no-op** (see §4) — the floating window's title bar (`FloatingChatWindowController.cs:194-207`) is permanently `"Chat {windowId}"`, unaffected by the command that's supposed to set it. +4. **No Ctrl+Left/Right word-jump or Ctrl+Backspace delete-word** — minor editing convenience absent from `UiField`'s `KeyDown` switch entirely (not chat-specific, but the chat input is the surface a user would notice it on most). + +--- + +## 4. Known no-ops and stubs (grepped, cited) + +| Site | What's disabled | +|---|---| +| `src/AcDream.Runtime/Chat/ClientCommandId.cs:50-58` (`SetChatTitle`) + `RetailClientCommandCatalog.cs:258-269` (`SetTitle` definition) | `@title ` — retail sets the popup chat window's title bar (`ClientCommunicationSystem::DoTitle @0x0057A640`); acdream's binding "is a pure no-op (the value is neither stored nor consumed — no title-bar chrome exists to render it yet, AP-182)". Confirmed live: `FloatingChatWindowController.cs:204` hardcodes `$"Chat {windowId}"` with no seam for an external override. | +| `src/AcDream.App/Input/GameplayInputCommandController.cs:208-213` | `InputAction.ToggleChatEntry` (Tab, bound at `KeyBindings.cs:251`) — the switch case's own comment says "IDevToolsGameplayCommands.FocusChatInput() retired... Tab is still consumed here, matching the prior no-op's 'handled' contract." **Harmless**: `UiRoot.OnKeyDown` (§3) independently handles Tab-to-focus-chat before/alongside this path, so functionally nothing is lost — but the `InputAction`/keybind plumbing for it is dead weight that could mislead a future reader into thinking this is the live mechanism. | +| `src/AcDream.App/Input/GameplayInputCommandController.cs` (whole file) | `InputAction.EnterChatMode` (Enter, bound at `KeyBindings.cs:252`) has **no case at all** in `Handle`'s switch (`GameplayInputCommandController.cs:172-235`) — falls to `default: return false;`. Same "harmless because `UiRoot` does it independently" caveat as `ToggleChatEntry` above. | +| `src/AcDream.Runtime/Chat/RetailClientCommandCatalog.cs:634-696` (`TryMatchAllegiance`) | 9 of 12 `@allegiance` subcommands (boot/ban/officer/title/motd/name/lock/house/chat/broadcast) unported — issue #360, still OPEN (see §5). | +| `src/AcDream.Runtime/Chat/RetailCommandHelpTable.cs:319-349` | `@day`/`@log`/`@render` recognized only by `/help `; execution falls through to server passthrough — issue #361, still OPEN (see §5). | +| `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs` | Entire `IPanel`-based class — see §6, dead code, not reachable from any production construction site. | + +No other `deferred`/`TODO`/`stub`/`placeholder` hits inside `ChatWindowController.cs`, `FloatingChatWindowController.cs`, or the `Panels/Chat/` directory resolve to a genuine behavioral gap beyond what's listed above and in §5 — the remaining grep hits in those files are either doc-comment cross-references to *other* code's no-ops (e.g. `ChatWindowController.cs:230` explaining that the main filter is "not an inert no-op" — i.e. describing something that was FIXED) or historical narration. + +--- + +## 5. Open issues — current code status + +| Issue | One-line verdict | Evidence | +|---|---|---| +| **#358** Ctrl+M mute chord never fires | **STALE — DONE.** `KeyBindings.RetailDefaults()` now binds Ctrl+M (`KeyBindings.cs`, per the fix note); root cause (binding added to the dead `AcdreamCurrentDefaults()` table) is fixed. Live-client verification of the actual mute effect was still owed at closure time — UNKNOWN whether that connected check ever ran. | +| **#359** `0x019E` PlayerKilled prints to participants | **STILL OPEN.** `ChatLog.OnPlayerKilled` (`ChatLog.cs:188-206`) appends the death message unconditionally for every recipient — no `player_id == victim \|\| player_id == killer` guard exists anywhere in the method or its call site. | +| **#360** `@allegiance`/`@house` only port simple subcommands | **STILL OPEN.** `RetailClientCommandCatalog.TryMatchAllegiance` (`RetailClientCommandCatalog.cs:656-696`) only recognizes `hometown`/`ho` and `info`; every other subcommand falls to `AllegianceUnrecognizedSubcommand`'s refusal text (`:689-695`). House subcommands correctly passthrough to ACE per the same file's `TryMatchHouse` comments (`:643`), but neither dispatcher executes the ~22 unported subcommands locally. | +| **#361** `@day`/`@log`/`@render` recognized in help only | **STILL OPEN.** `RetailCommandHelpTable.cs:319-349` still carries the "NOT YET IMPLEMENTED in acdream" meta-tail for all three; `RetailClientCommandCatalog` has no `Day`/`Log`/`Render` client-command definitions with real handlers (only chat verbs actually wired execute; these three fall through to server passthrough, which is a silent no-op against ACE). | +| **#362** Four CH4 outbound requests had no inbound handler | **STALE — DONE**, closed 2026-08-09 (`ClientCommandResponses.cs` parses `ChannelIndex`/`ChannelList`/`AvailableHouses`/`AllegianceInfoResponse`). | +| **#363** Refusal sites typed `0x00` where retail types `0x1A` | **STALE — CLOSED 2026-08-10.** `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (`ChatVM.cs:159-183`) exists and is wired at `InteractionRetainedUiComposition.cs:472-473`; `ChatCommandRouter` routes every named site through it (confirmed at `ChatCommandRouter.cs:85-98,119-121,154,162`). | +| **#366** New-unseen-text indicator (`0x1000048C`) imports but unwired | **STILL OPEN** (narrowed 2026-08-16 — the build/import half is fixed, the behavior half — what triggers it, what a click does — remains un-researched). No controller code references `0x1000048C` in `ChatWindowController.cs`. | +| **#367** Local-presentation fallbacks land in chat scroll, not SpewBox | **STALE — CLOSED 2026-08-10**, closed as a side effect of #363 (same seam). | +| **#369** Unconfirmed whether floaty windows share the main window's talk-focus channel | **STILL OPEN, unresearched.** `FloatingChatWindowController.cs:157` hardcodes Say; whether that's retail-correct is not established either way — filed as a research task, not yet picked up. | +| **#372** Options panel Character/Chat/Config tabs render blank | **STALE — DONE** (blank-tabs half fixed at the `UiTemplateListBox` viewport-anchor level, not chat-specific; the tangential "13 Chat-tab filter labels resolve blank" sub-note was fixed by the `FilterStringTableId = 0x2300000D` correction visible at `ChatOptionsPageController.cs:96-105`). | +| **#379** Chat opacity applied to all windows, not just chat | **STALE — DONE**, `RetailWindowOpacityController` now scoped to the 5 chat windows only. | +| **#380** Chat tab opacity sliders missing row captions | **STALE — DONE**, `ChatOptionsPageController.cs:399-407,473-503` wires `SetOpacityCaption` from `ChatOptionsDatCaptions`. | +| **#382** Floating-window indicator buttons invisible until hovered | **STALE — DONE**, `UiButton.TrySetRetailState` fix (unrelated file, general `UiButton` bug that happened to be discovered via the chat indicators). | + +**Net: of the 12 chat-tagged issues checked, 4 remain genuinely open in code (#359, #360, #361, #366) plus one unresearched design question (#369).** The rest closed since the digest's 2026-08-09/10 snapshot but the digest's own "Open" section (still listing #358/#359/#360/#361/#362/#363) is now stale for #358/#362/#363 — worth a digest refresh independent of this audit. + +--- + +## 6. Test coverage + +**Well covered** (direct, behavior-level tests exist): +- `ChatWindowController`: `tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs` — bind success/failure, talk-focus specials (Squelch/Tell-to-selected), transcript parent/mode, transcript layout caching + out-of-range LogTextType fallback, input submit → `SendChatCmd`, channel-change updates submit channel, input-field resize/reflow (both with and without an imported `LayoutPolicy`), indicator open/closed/cross-window-isolation/out-of-range. +- `FloatingChatWindowController`: `tests/AcDream.App.Tests/UI/Layout/FloatingChatWindowControllerTests.cs` — bind success/failure/invalid-window-id, transcript parent, input parent (floaty row vs main bar), input always-Say, per-window filter subset + filter-change reflection + cache reuse. +- `ChatWindowState`: has its own filter/open/`ShouldDisplay` logic covered by construction (not independently verified in this pass, but the class is simple enough that the controller tests above exercise it transitively). +- `UiField`: `tests/AcDream.App.Tests/UI/UiFieldTests.cs` — insert/caret, backspace, submit/clear/history-push, empty-submit no-op, history up/down, history 100-cap, two multi-line-after-shrink regression tests (the 2026-07-29 crash class), character filter, select-all-on-focus, read-only field, multi-line Enter-inserts-newline. +- Command routing: `ChatCommandRouterTests.cs`, `ChatInputParserTests.cs`, `ChatInputParserAtPrefixTests.cs`, `RetailClientCommandCatalogTests.cs`, `RetailCommandHelpTableTests.cs`, `RetailCommandRegistryConformanceTests.cs` (bidirectional ownership-rule enforcement across the whole 152-verb registry). +- Colors: `RetailChatColorTableTests.cs`. + +**Gaps found — user-visible behaviors with zero automated coverage:** +1. **`ChatWindowController.ToggleMaximize`** — no test anywhere calls it or exercises `CaptureWindowState`/`RestoreWindowState`. Grep for `ToggleMaximize`/`Maximiz` across `tests/` returns nothing. The growUp/clamp/DAT-constraint logic (`ChatWindowController.cs:537-580`, a direct port of `gmMainChatUI::HandleMaximizeButton`) is entirely unverified by automation — a regression here would only be caught by a human clicking the max/min button. +2. **Floating window's Close button** — no test exercises `FloatingChatWindowController.cs:211-214`'s `OnClick` wiring (`WindowHandle?.Hide()`). Grep for `CloseButton` in `FloatingChatWindowControllerTests.cs` returns nothing. +3. **`UiField` Escape handling** (or lack thereof — see §3 finding 1) — no test exists for Escape at all in `UiFieldTests.cs`; the absence of behavior is untested, meaning it could silently "start working" or silently regress further with no signal either way. +4. **`UiField` clipboard (Ctrl+C/X/V) and Shift-selection** — none of `UiFieldTests.cs`'s 13 tests exercise `CopySelection`/`CutSelection`/`Paste`/Shift+arrow extension. `Selectable`-gating (§3) is only indirectly confirmed via the DAT-fixture conformance test (`ChatLayoutConformanceTests.cs:220-228`), which checks the *property resolves true*, not that copy/cut/select-all *actually work* once it's true. +5. **`ChatPanel.cs` and its whole test suite are exercising dead code.** `ChatPanel` (`src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs`) implements the old ImGui-era `IPanel` contract from the D.2a stack. `AcDream.UI.ImGui` no longer exists as a project (deleted at Campaign V slice V11 per `CLAUDE.md`), and a repo-wide grep for `new ChatPanel(` finds only the class's own constructor declaration — **no production code anywhere constructs a `ChatPanel`.** Its five test files (`ChatPanelFocusTests.cs`, `ChatPanelInputTests.cs`, `ChatPanelLayoutTests.cs`, plus the shared `ChatVMCombatTests.cs`/`ChatVMLastTellSenderTests.cs`/`ChatVMRetellAndProvidersTests.cs` that exercise `ChatVM` directly and remain legitimately live) still compile and pass, which gives a false impression of "chat input is covered" in a naive test-count read — the REAL live input surface is `UiField` + `ChatWindowController`, covered separately (and less deeply, per findings 1-4 above). This is worth flagging to whoever next touches chat tests: `ChatPanel.cs` and its three panel-specific test files are candidates for deletion (dead code, not a functioning fallback), not maintenance targets. + +--- + +## 7. Prioritized gap list (most user-visible first) + +This is the ordering to plan slices from — judgment calls, not a flat dump. + +1. **Escape does nothing in the chat input (§3 finding 1).** Every retail player's muscle memory includes "Escape backs out of whatever I'm doing," and chat is the single most-used text-entry surface in the client. Right now it's a dead key while typing — worse than doing nothing wrong, because it silently swallows an action a user expects to work (defocus/clear), and if `WorldKeyFallThrough` were ever wired for something else, it would ALSO be swallowed by the exact-focus branch that already fails to handle it. This is a real, previously-untracked bug (no ISSUES.md entry), high frequency of exposure, small fix surface (add an Escape case to `UiField.OnEvent`'s `KeyDown` switch, decide clear-vs-defocus-vs-both against retail). +2. **#360 — `@allegiance`/`@house` management subcommands (22 of them unported).** Highest-traffic gap by command surface area; already tracked, already scoped ("largest single item; deserves its own slice" per the issue's own text), needs byte-level wire verification before implementation (target-name/guid resolution, confirmation dialogs, multi-field payloads) rather than guessing. +3. **#359 — PlayerKilled line double-prints for the victim/killer.** Small, well-scoped, single-method fix (`ChatLog.OnPlayerKilled` needs the local-player-guid participant check) with a clear retail citation already in the issue. Low effort, directly visible to anyone who dies or gets a kill in acdream. +4. **#369 — floaty-window channel-sharing research.** Currently a design assumption (Say-always) shipped without verification. Low implementation cost either way once researched, but the research itself (`gmCCommunicationSystem`'s floaty send path) hasn't started. Worth resolving before more chat work builds on the current assumption. +5. **`ToggleMaximize`/Close-button test coverage gap (§6.1/6.2).** Not a behavior bug — both features work per the code reading — but zero automated coverage on two interactive, DAT-geometry-dependent code paths (max/min clamping, close-then-reopen) is a latent regression risk given how much chat-adjacent layout churn this codebase has had (8+ chat-parity review rounds in the last two weeks alone). +6. **#366 — new-unseen-text indicator inert.** Cosmetic/discoverability only; retail's exact trigger condition is still unresearched, so this can't be fixed correctly without that research first, and its absence doesn't block any other chat behavior. +7. **#361 — `@day`/`@log`/`@render`.** Genuinely low-value: `@day` needs a renderer hook that doesn't exist yet (bigger than a chat fix), `@log` was deliberately deferred (file-handle lifecycle risk across reconnects), `@render` has no acdream render-option surface to bind to. Correctly the lowest priority of the open command-registry gaps. +8. **`@title` no-op + hardcoded floaty titles (§4).** Cosmetic, single command, no other feature depends on it. Fine to bundle with a future title-bar-chrome pass rather than a standalone fix. +9. **`ChatPanel.cs` dead-code cleanup (§6.5).** Not a behavior gap at all — it's hygiene. Flagging here rather than fixing inline per this audit's report-only scope; worth a small follow-up to delete the class and its now-misleading test files so future coverage audits don't need to re-discover this. +10. **Missing autocomplete / word-jump editing conveniences (§3 findings 2, 4).** Lowest priority: unconfirmed whether retail even had these, and even if it did, they're minor efficiency features, not correctness or discoverability gaps. + +--- + +## Appendix: files read for this audit + +- `src/AcDream.App/UI/Layout/ChatWindowController.cs` +- `src/AcDream.App/UI/Layout/FloatingChatWindowController.cs` +- `src/AcDream.Core/Chat/ChatWindowState.cs` +- `src/AcDream.Core/Chat/ChatLog.cs` +- `src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs` +- `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs` +- `src/AcDream.Runtime/Chat/ChatCommandRouter.cs` +- `src/AcDream.Runtime/Chat/ClientCommandId.cs` +- `src/AcDream.Runtime/Chat/RetailClientCommandCatalog.cs` (partial) +- `src/AcDream.Runtime/Chat/RetailCommandHelpTable.cs` (partial, grep-targeted) +- `src/AcDream.App/UI/UiField.cs` +- `src/AcDream.App/UI/UiMenu.cs` (partial) +- `src/AcDream.App/UI/UiRoot.cs` (partial — key dispatch + focus) +- `src/AcDream.UI.Abstractions/Input/InputDispatcher.cs` (partial) +- `src/AcDream.App/Input/GameplayInputCommandController.cs` (partial) +- `src/AcDream.App/Input/InputCaptureSources.cs` (partial) +- `src/AcDream.App/UI/Layout/ChatOptionsPageController.cs` +- `src/AcDream.App/UI/RetailUiRuntime.cs` (partial — persistence + mount) +- `src/AcDream.App/UI/WindowNames.cs` +- `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs` (partial) +- `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (partial — Type-12 field build) +- `tests/AcDream.App.Tests/UI/UiFieldTests.cs` +- `tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs` +- `tests/AcDream.App.Tests/UI/Layout/FloatingChatWindowControllerTests.cs` +- `tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs` (partial) +- `docs/ISSUES.md` (targeted sections: #358-#382 chat-tagged range) +- `docs/architecture/retail-divergence-register.md` (targeted: AP-185 through AP-191) +- `C:\Users\erikn\.claude\projects\C--Users-erikn-source-repos-acdream\memory\project_chat_digest.md` diff --git a/docs/research/2026-08-21-acdream-text-stack-audit.md b/docs/research/2026-08-21-acdream-text-stack-audit.md new file mode 100644 index 00000000..a55f89b6 --- /dev/null +++ b/docs/research/2026-08-21-acdream-text-stack-audit.md @@ -0,0 +1,376 @@ +# acdream text-stack audit: seam for retail glyph-level tagged text + +**Scope.** Research only — OUR codebase (`src/AcDream.App/UI/**`, +`src/AcDream.UI.Abstractions/Panels/Chat/**`, `src/AcDream.Core/Chat/**`). +Goal: determine what it takes to support retail's glyph-level tagged +text (a differently-colored, clickable name inside an otherwise +uniformly-colored chat line). No code changes made. + +## 1. Current model — what is a rendered text line? + +`UiText` (`src/AcDream.App/UI/UiText.cs`) is the one retained-UI text +widget (`RegisterElementClass(0xc)`, class doc at `UiText.cs:10-22`). +It has **two** display-line shapes, both single-color: + +- **`Line`** — `UiText.cs:50`: + `public readonly record struct Line(string Text, Vector4 Color);` + One string, one `Vector4` color for the WHOLE string. This is what + `LinesProvider` (`UiText.cs:62`, `Func>`) returns + and what the scrollable multi-line transcript path renders + (`DrawClippedText`, `UiText.cs:636-691`). Color is **per-line**, not + per-run: `lines[i].Color` (`UiText.cs:673`/`677`) is one value passed + whole to `ctx.DrawStringDatPass`/`ctx.DrawString`. +- **`TextRun`** — `UiText.cs:55`: + `public readonly record struct TextRun(string Text, Vector4 Color);` + Multiple colored fragments concatenated onto **one** authored line, + fed by `RunsProvider` (`UiText.cs:69`, + `Func>?`) and drawn by `DrawSingleLineRuns` + (`UiText.cs:693-754`). This is real per-run coloring — each run gets + its own `ctx.DrawStringDatPass` call at its own pen X + (`UiText.cs:725-743`) — but it is **only reachable when + `OneLine == true`** (`UiText.cs:506-510`: `if (OneLine && + RunsProvider is { } runsProvider)`), i.e. the static single-line + label path. The chat transcript is NOT `OneLine` (`ChatWindowController.cs:320`: + `c.Transcript.OneLine = false;`), so it can never reach + `DrawSingleLineRuns` — the scrollable multi-line path only ever + reads `LinesProvider`/`Line`. + +**Answer to Q1:** color today is per-`Line` in the transcript +(scrollable, multi-line, bottom-pinned/word-wrapped) path, and +per-`TextRun` only in the unrelated static single-line label path +(currently used by exactly one controller — see §5/§6). Chat uses the +former exclusively. + +## 2. Where the flattening happens (the key finding) + +`ChatEntry` (`src/AcDream.Core/Chat/ChatLog.cs:499-536`) is a +structured record: `Sender` (string), `SenderGuid` (uint), `Text`, +`ChannelId`/`ChannelName`, `Kind`, `LogTextType`. The sender's identity +survives as a distinct field all the way through `ChatLog`. + +It is destroyed in **two** steps inside `ChatVM` +(`src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs`), and the second +step is the point of no return: + +**Step A — string composition.** `ChatVM.FormatEntry` +(`ChatVM.cs:262-313`) string-interpolates `entry.Sender` directly into +the message prose, e.g. for `ChatKind.LocalSpeech` +(`ChatVM.cs:269-271`): +``` +ChatKind.LocalSpeech => IsOwnSpeaker(entry.Sender) + ? $"You say, \"{entry.Text}\"" + : $"{entry.Sender} says, \"{entry.Text}\"", +``` +After this call the sender name is prose inside one `string`; there is +no longer a machine-readable boundary marking where "Name" ends and +"says, ..." begins. + +**Step B — metadata drop (the actual point of no return).** +`ChatVM.RecentLinesDetailed()` (`ChatVM.cs:345-371`) builds the +`FormattedLine` record (`ChatVM.cs:384-388`): +``` +public readonly record struct FormattedLine( + string Text, + ChatKind Kind, + CombatLineKind? CombatKind, + uint LogTextType); +``` +`FormattedLine` does **not** carry `Sender` or `SenderGuid` at all — +only the composed `Text`, `Kind`, `CombatKind`, and the retail color +key `LogTextType`. Every downstream consumer +(`ChatWindowController.GetTranscriptLines`, `ChatWindowController.cs:736-781`, +which calls `vm.RecentLinesDetailed()` at `ChatWindowController.cs:753`, +then `ChatTranscriptRenderer.BuildLines`, +`src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs:67-95`) only ever +sees the flat `Text` string plus one `LogTextType` per entry. +`ChatTranscriptRenderer.BuildLines` then assigns exactly **one** +`Vector4 currentColor` per entry (resolved once from `LogTextType` at +`ChatTranscriptRenderer.cs:89`) and stamps every word-wrapped fragment +of that entry with that single color (`ChatTranscriptRenderer.cs:90-93`): +``` +if (RetailChatColorTable.TryGetColor(d.LogTextType, out Vector4 resolved)) + currentColor = resolved; +foreach (string frag in WrapText(d.Text, maxW, measure)) + result.Add(new UiText.Line(frag, currentColor)); +``` + +**So: the sender's identity (name + guid) is available up through +`ChatLog`/`ChatEntry`, is baked into prose by `ChatVM.FormatEntry`, and +is then dropped entirely — not merely flattened, but discarded — by +`ChatVM.RecentLinesDetailed`'s `FormattedLine` shape.** By the time a +`UiText.Line` exists, there is no span boundary, no guid, and (because +word-wrap has already run) not even a guarantee that "the sender name" +is wholly contained within a single rendered `Line` if it happened to +sit at a wrap boundary. Any attempt to recover "where is the name" +downstream of this point would have to regex/string-match the composed +prose back apart — fragile (a message body containing the speaker's +own name, or a name that is a prefix of a common word, breaks it) and +still has no guid to attach for the click action. + +## 3. Draw path + +**One call per line/run, never per-glyph-color-batch beyond that.** +The scrollable path (`DrawClippedText`, `UiText.cs:636-691`) issues one +`ctx.DrawStringDatPass(datFont, text, x, y, color, isOutlinePass)` +(`UiText.cs:687-689`) per visible `Line` (whole wrapped fragment, one +color). The DAT-font backend (`UiRenderContext.DrawStringDatPass`, +`src/AcDream.App/UI/UiRenderContext.cs:320-367`) walks every glyph in +that ONE string with ONE `tint` (`UiRenderContext.cs:321`, `tint` +passed once, applied per-glyph at `UiRenderContext.cs:359-362`) — there +is no per-glyph or per-substring color inside a single +`DrawStringDatPass` call. + +**Drawing N differently-colored runs on one visual line requires N +separate `DrawStringDatPass` calls, each starting at its own pen X** — +this is exactly the mechanism `DrawSingleLineRuns` +(`UiText.cs:693-754`) already uses: it measures each run's width +(`datFont.MeasureWidth(run.Text)`, `UiText.cs:709`/`736`), accumulates +a `penX` (`UiText.cs:731-737`), and issues one +`ctx.DrawStringDatPass(datFont, run.Text, run.X, y, run.Color, +isOutlinePass: false)` per run (`UiText.cs:742-743`). The outline pass +is batched block-wide first (all runs' outlines, then all runs' fills — +`UiText.cs:739-743`, same reasoning as the multi-line block-batching +documented at `UiRenderContext.cs:306-318`) so a per-run outline +doesn't notch an adjacent run's descender. + +**Yes, the DAT font path supports starting a draw at an arbitrary X +offset and measuring a substring's width.** `UiDatFont.MeasureWidth(string +text)` (`src/AcDream.App/UI/UiDatFont.cs:160-172`) sums per-glyph +advances for any string/substring — already used for substring +measurement in the selection-highlight code +(`UiText.cs:656-657`/`661-662`, `datFont.MeasureWidth(text.Substring(0, +c0))`). `DrawStringDatPass`/`DrawStringDat` take an arbitrary `float x` +(`UiRenderContext.cs:277-278`, `320-321`) with no assumption it starts +at the element's left edge. The bitmap-font fallback (`BitmapFont.cs`, +`MeasureWidth` at `BitmapFont.cs:167`) and `UiRenderContext.DrawString` +(`UiRenderContext.cs:188-203`, also takes an arbitrary `float x`) mirror +the same capability. **Conclusion: the low-level draw primitives +already support everything a run-based transcript line needs — no +renderer/font work is required, only a widget-level model change to +call them N times instead of once.** + +## 4. Hit-testing + +**No sub-line hit-testing exists today; click/hover route to whole +elements, never to a text span.** `UiRoot.HitTestTopDown` +(`src/AcDream.App/UI/UiRoot.cs:1410-1430`) walks the retained tree via +`UiElement.HitTest` (`src/AcDream.App/UI/UiElement.cs:705-731`), which +recurses into children and, failing that, calls the virtual +`OnHitTest(localX, localY)` (`UiElement.cs:563-564`, default is a +rectangle containment check) — the granularity is always "some +`UiElement`", never "some substring of a `UiElement`'s text." A +resolved hit becomes a `Click` `UiEvent` at `UiRoot.OnMouseUp` +(around `UiRoot.cs:994-997`) and bubbles via +`UiRoot.BubbleEvent`/`UiElement.OnEvent` (`UiRoot.cs:1516-1525`, +`UiElement.cs:571`). `UiText.OnEvent`'s `Click` case +(`UiText.cs:809-813`) fires the single `OnClick` delegate for the +WHOLE element — there is no notion of "which run was clicked." + +**The pieces needed already exist, just not wired to `Click`.** +`UiText.HitChar(float localX, float localY)` (`UiText.cs:1031-1050`) +already converts a local point into a `Pos(line, col)` caret position +using the cached draw geometry (`_lastLines`/`_lastBaseY`/ +`_lastLineHeight`, `UiText.cs:268-273`) and a per-character advance +lookup (`UiText.cs:1042-1048`, works for both `UiDatFont` and +`BitmapFont`) — but it is currently invoked only from the +selection-drag path (`MouseDown`/`MouseMove` cases, `UiText.cs:826-847`), +gated behind `Selectable` (`UiText.cs:828`, `839`). The chat transcript +IS `Selectable = true` (`ChatWindowController.cs:321`), so `HitChar` +already runs on every mouse-down inside the transcript — it is simply +never asked "which run (if any) covers this `(line, col)`", because +`Line` carries no runs to check against. + +**What mapping a click to a run would need:** +1. A per-line list of run boundaries (start col, end col, and a + payload — e.g. sender guid) reaching `UiText` alongside the text, + which does not exist today (`Line` has no such field, see §1/§2). +2. `HitChar`'s existing `(line, col)` result checked against that + list — this is a small, local addition to `UiText`, not a new + hit-test mechanism. +3. A dispatch from "run payload resolved" to an actual action (e.g. + pre-filling a `/tell ` in the chat input) — analogous to the + existing `OnClick` delegate, but keyed by run rather than by + element. + +No retail-side click semantics (what a name-click does) were +researched here — that is a different agent's lane per the task brief. + +## 5. Seam proposal + +**Given Code Structure Rules (CLAUDE.md "Code Structure Rules" §1-3):** +- `AcDream.Core` must not depend on window/GL/backend projects (rule 2) + — `ChatEntry`/`ChatLog` (Core) can carry the STRUCTURED data a tagged + run needs (sender name + guid + explicit text-span boundaries) but + must not know about `Vector4`/GL/rendering. +- UI panels target `AcDream.UI.Abstractions` only (rule 3) — the + composition of "structured entry -> ordered list of colored, + optionally-actionable spans" is exactly the kind of pure formatting + logic `ChatVM` already owns (`ChatVM.FormatEntry`/ + `RecentLinesDetailed`, `ChatVM.cs:262-371`) and should keep owning; + it must not reach into `AcDream.App` (GL/rendering) types. +- The retained-widget rendering (`AcDream.App/UI/UiText.cs`) is where + GL-adjacent draw calls (`DrawStringDatPass`) and Silk.NET-adjacent + hit-testing (`HitChar`, `UiRoot`) live, and must stay there. + +**Proposed layering (three seams, one per project boundary):** + +1. **`AcDream.UI.Abstractions` (data shape)** — introduce a + run-carrying line shape parallel to (not replacing) `FormattedLine`. + Sketch: `FormattedLine` gains an optional ordered list of spans, or + a new `RichFormattedLine(IReadOnlyList Runs, ...)` is + added, where `FormattedRun` is something like + `(string Text, uint? ActorGuid, bool IsSpeakerName)` — deliberately + NOT carrying a color yet (`AcDream.UI.Abstractions` has no + `System.Numerics`/GL dependency requirement today, but keeping + color resolution in `AcDream.App` mirrors the existing + `RetailChatColorTable`/`ChatTranscriptRenderer` split, where + `ChatVM` supplies `LogTextType`/structured data and + `ChatTranscriptRenderer` in `AcDream.App` resolves it to `Vector4`). + `ChatVM.FormatEntry` (`ChatVM.cs:262-313`) would need a sibling that + returns spans instead of one interpolated string — e.g. split each + `case` into "prefix run" / "sender run" / "suffix run" instead of a + single `$"..."` — and `RecentLinesDetailed` would carry + `entry.Sender`/`entry.SenderGuid` through instead of discarding them + (the §2 fix). +2. **`AcDream.App/UI/Layout` (composition)** — `ChatTranscriptRenderer.BuildLines` + (`ChatTranscriptRenderer.cs:67-95`) is the existing per-controller- + shared seam that already resolves `LogTextType -> Vector4` and + word-wraps. It would gain a variant that word-wraps a RUN LIST + instead of a flat string per entry, producing a new "rendered line + with runs" shape (see below) instead of `UiText.Line`. Both + `ChatWindowController` (`ChatWindowController.cs:778`) and + `FloatingChatWindowController` (same shared function, per the class + doc at `ChatTranscriptRenderer.cs:9-15`) would switch to the new + builder — this is the one place both chat surfaces already share, + so it is the natural single point of change for chat specifically. +3. **`AcDream.App/UI/UiText.cs` (widget)** — this is where the actual + gap is. `Line` needs an additive `Runs` concept for the + MULTI-LINE (`OneLine == false`) path, not just the existing + `OneLine`+`TextRun` path (§1). Minimal shape: extend `Line` (or add + a parallel `RichLine`) to carry `IReadOnlyList` alongside + or instead of a flat `string Text` + single `Vector4 Color`; change + `DrawClippedText`'s multi-line loop (`UiText.cs:636-691`) to, for a + line with runs, do what `DrawSingleLineRuns` already does per-run + (walk runs, accumulate `penX`, call `DrawStringDatPass` per run, + batch all outlines-then-all-fills at the BLOCK level exactly as + `UiText.cs:681-690` already batches across LINES today — extending + that batching one level deeper, across runs within lines, is + mechanical). `HitChar` (`UiText.cs:1031-1050`) needs to additionally + resolve which run (if any) contains the hit `col`, and `OnEvent`'s + `Click` case (`UiText.cs:809-813`) needs a second dispatch path + (run-click, distinct from whole-element `OnClick`) that a controller + (e.g. `ChatWindowController`) can bind to "prefill a tell to this + guid," mirroring how `OnClick` is bound today. + +**Existing abstraction that already almost does this:** +`TextRun`/`RunsProvider`/`DrawSingleLineRuns` (§1, `UiText.cs:55,69,693-754`) +is the closest precedent — it proves the draw-side mechanics (measure +run, accumulate pen, per-run `DrawStringDatPass`, block-batched +outline) already work and are exercised in production by +`CharacterStatController.BuildSelectedTitleRuns` +(`src/AcDream.App/UI/Layout/CharacterStatController.cs:1425-1454`, +wired at `CharacterStatController.cs:1680`) for a skill/attribute title +with a colored numeric delta suffix. It is currently scoped to +`OneLine` only and carries no click/actor payload — extending it to +the multi-line/wrapped path and adding a payload field is smaller than +building a new mechanism from scratch. `DatRichText` +(`src/AcDream.App/UI/Layout/DatRichText.cs`, `Segment(string? Text, +Vector4 Color)` at `DatRichText.cs:40`, `Compose` at +`DatRichText.cs:52-88`) is a second, partially-overlapping precedent: +it already composes multiple colored segments for a multi-line box, +but it word-wraps EACH segment independently and concatenates the +results as separate `Line`s (`DatRichText.cs:83-84`) — so two segments +that would visually share one wrapped row are NOT joined onto that row +today; it solves "multiple colors across a paragraph's several lines," +not "multiple colors sharing one rendered row." A tagged-name-in-chat +feature needs the latter (the name and the rest of the sentence share +row 0 of a possibly-multi-row wrapped message), so neither existing +mechanism is a drop-in — both inform the shape of the fix. + +## 6. Blast radius + +`UiText.Line`/`LinesProvider` is used extremely broadly — 52 files +reference `LinesProvider = ` and 53 reference `UiText.Line(`/`new +UiText()` (full grep list retained below). **If the change is additive** +(new optional `Runs` field/type alongside the existing `Line`, default +behavior unchanged for every caller that keeps returning plain +`Line`s), the blast radius for BEHAVIOR is limited to whichever +controllers opt in (initially: chat only). The blast radius for +BUILD/COMPILE risk (anything that touches `UiText.cs`, `UiElement.cs` +recompiles the whole `AcDream.App` UI layer) and for REVIEW is the full +list below, grouped by category — every one of these should be +smoke-tested after a `UiText`-internal change even if it doesn't touch +their own code: + +- **Chat (the actual feature target):** + `ChatWindowController.cs`, `FloatingChatWindowController.cs`, + `ChatTranscriptRenderer.cs`, `SpewBoxController.cs` + (`src/AcDream.App/UI/SpewBoxController.cs` — retail's other + colored-text-scroll surface, `RetailLogTextType.ClientLocal` per + `ChatVM.cs:159-183`; likely wants the SAME run model eventually since + it renders `LogTextType`-colored lines too). +- **Tooltips:** `RetailTooltipPresenter.cs` — world/UI hover tooltips; + currently plain `Line`s. +- **Appraisal / item & creature reports:** `AppraisalUiController.cs`, + `CreatureAppraisalRows.cs`, `ItemAppraisalReport.cs` — these already + render multi-colored informational text (spell names, damage types) + as SEPARATE `Line`s per colored fragment (one color per whole line, + not per run) — a run model could simplify these, or they could stay + as-is if row-granularity coloring already meets retail fidelity + there (not assessed here — out of this audit's scope). +- **Social panels:** `SocialSquelchPageController.cs`, + `SocialAllegiancePageController.cs`, + `SocialFellowshipPageController.cs`, `SocialFriendsPageController.cs` + — friends/allegiance/fellowship rows; per CLAUDE.md's Campaign FA + notes these already do per-state color swaps on `UiText`, a + different (not run-based) mechanism. +- **Vendor / trade:** `VendorUiController.cs`, + `SecureTradeUiController.cs`. +- **Combat / spellcasting:** `CombatUiController.cs`, + `SpellcastingUiController.cs`, `EffectsUiController.cs`. +- **Dialogs:** `RetailWaitDialogView.cs`, `RetailMessageDialogView.cs`, + `RetailConfirmationDialogView.cs`, + `RetailConfirmationTextInputDialogView.cs`. +- **Character sheet / creation:** `CharacterStatController.cs` (the + existing `TextRun` consumer, §5), `CharacterCreationSkillsPage.cs`, + `CharacterCreationSummaryPage.cs`, `CharacterCreationTownPage.cs`, + `CharacterCreationProfessionPage.cs`, + `CharacterCreationHeritagePage.cs`, + `CharacterManagementUiController.cs`. +- **Options / config:** `ConfigOptionsPageController.cs`, + `ChatOptionsPageController.cs`, `CharacterOptionsPageController.cs`, + `KeyboardConfigController.cs`. +- **Misc panels:** `InventoryController.cs`, `RadarController.cs`, + `MapPageController.cs`, `HousePageController.cs`, + `LinkStatusUiController.cs`, `VitaeUiController.cs`, + `VitalsController.cs`, `RetailFpsController.cs`, + `SelectedObjectController.cs`, `IndicatorDetailText.cs`, + `ComponentBookTemplateFactory.cs`, `EffectRowTemplateFactory.cs`, + `CharacterController.cs`, `DatWidgetFactory.cs` (the factory that + builds every `UiText` from LayoutDesc — touches all of the above by + construction). +- **Tests:** `UiTextTests.cs`, + `SocialFellowshipPageControllerTests.cs`, + `SocialPanelControllerTests.cs`, `RowTemplateResolverTests.cs`, + `DatWidgetFactoryTests.cs`, `CharacterStatControllerTests.cs`, + `VitalsBindingTests.cs`, `AppraisalUiControllerTests.cs` — any of + these that assert on `UiText.Line` shape/count would need review if + `Line`'s shape changes (not if a new type is added additively). + +**Net:** an ADDITIVE seam (new run-carrying line type, existing `Line` +untouched) keeps the functional blast radius to chat (and optionally +SpewBox) while still requiring the whole `AcDream.App/UI` tree to +rebuild/retest since it all depends on `UiText.cs`/`UiElement.cs`. A +seam that changes `Line`'s existing shape would force a review pass +across every file in the list above. + +## What this audit did NOT do + +- Did not research retail's own tagged-glyph-run mechanism (separate + agent's lane per the task brief). +- Did not propose or write any code change — `Line`/`TextRun`/`Segment` + shapes above are illustrative sketches for sizing, not a spec. +- Did not assess whether `AppraisalUiController`/`CreatureAppraisalRows`'s + existing one-color-per-`Line` approach is already retail-faithful for + their own content (out of scope; flagged only as a blast-radius + member). diff --git a/docs/research/2026-08-21-chat-tag-click-dispatch.md b/docs/research/2026-08-21-chat-tag-click-dispatch.md new file mode 100644 index 00000000..41d183fb --- /dev/null +++ b/docs/research/2026-08-21-chat-tag-click-dispatch.md @@ -0,0 +1,543 @@ +# Chat log click-to-tag dispatch (retail, Sept 2013 EoR build) + +Research-only. Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` +(PDB-named pseudo-C) and `docs/research/named-retail/acclient.h` (verbatim +retail struct layouts). Every claim below is cited `symbol @ 0xADDRESS`. +Binary Ninja's rendering caveats (misleading compare idioms, ~33-char +truncated inline strings) are called out inline wherever they bit this +investigation. + +## TL;DR + +The suspected anchor `ChatInterface::SetReplyTextInChatBox @ 0x004F4760` is +**not** the click handler. It is a keyboard text-replacement macro +(`/t `, `/tell `, `reply ` + space → `@tell ,`) wired through +`ChatInterface::HandleTextReplacements @ 0x004F50D0`, itself fired from a +"character typed" UI broadcast, not a mouse event. It happens to share the +`"@tell %s, "` idea with the real click path but is a separate code path +with separate (looser) text. + +The real click path is a generic, polymorphic **tag** system: + +``` +mouse button up over a UIElement_Text + → UIElement_Text::MouseUp @ 0x004694F0 + → UIElement_Text::DeterminePositionFromXY @ 0x004688F0 (screen xy → glyph index) + → GlyphList::InqGlyph @ 0x00473430 (glyph index → Glyph, incl. m_tag) + → TextTag::HandleClick (virtual, vtable+0x14) (dispatch by tag TYPE) + TextTag_IIDString::HandleClick @ 0x00478840 + → ECM_UI::SendNotice_TextTag_IIDStringClick @ 0x006927C0 + → gmMainChatUI::RecvNotice_TextTag_IIDStringClick @ 0x004CCE10 + (gate: tag m_type == 0x10000001, chat entry not already focused) + → ChatInterface::StartTell @ 0x004F41F0 + (writes "@tell , " into the entry, focuses it) +``` + +Player/speaker names in chat are wrapped by the server/client text +formatters in a `:>displayText<\Tell>` markup +span (note: closing marker is a **backslash**, `<\Tell>`, not a +forward-slash — confirmed from the raw literal at +`data_7d0bfc @ 0x007D0BFC` etc.). This markup is used for direct tells, +channel "says" lines, `[Fellowship]`, `[Co-Vassals]`, patron/vassal lines, +and (per the generic `[%ws] ...` format at +`data_7e83e8 @ 0x007E83E8`) ordinary named-channel chat too — i.e. **every** +chat line that shows a speaker name embeds the same tag, not just tells. +Clicking any of them always opens a **tell**, regardless of which channel +the line came from. + +--- + +## 1. Click → glyph → tag resolution + +### 1a. Entry point: `UIElement_Text::MouseUp` + +`UIElement_Text::MouseUp @ 0x004694F0` is registered directly in +`UIElement_Text`'s vtable slot for `MouseUp` (confirmed at the vtable dump, +e.g. `0079C1A4: MouseUp = UIElement_Text::MouseUp`). The relevant tail, +reached only when the mouse-up's button id was previously recorded as +mouse-down over this same element (`cond:0`, looked up in +`this->m_mouseDownTable` keyed by the button id `arg4`): + +```c +// UIElement_Text::MouseUp @ 0x004694F0, tail (0x0046959C-0x004695DD) +if (eax_4 != 0) // cond:0 — this button's mouse-down WAS on this element +{ + uint32_t eax_6 = UIElement_Text::DeterminePositionFromXY(this, ebp_2, edi_2); + Glyph var_24; + if (GlyphList::InqGlyph(&this->m_glyphList, eax_6, &var_24) != 0 && var_4 != 0) + *(uint32_t*)(*(uint32_t*)var_4 + 0x14)(arg4); // var_4->HandleClick(arg4) + Glyph::~Glyph(&var_24); +} +``` + +`ebp_2`/`edi_2` are the mouse position converted to element-local, +margin-adjusted coordinates a few lines earlier in the same function: + +```c +int32_t ebp_2 = ((arg2 - this->m_margL) - UIRegion::GetScreenX0(this)); +int32_t edi_2 = ((arg3 - this->m_margU) - UIRegion::GetScreenY0(this)); +``` + +`arg2`/`arg3` are the raw screen-space mouse coordinates passed down from +the UI event system; `m_margL`/`m_margU` are the element's left/top text +margins (`UIElement_Text` struct, `acclient.h:53412-53415`). + +**BN caveat**: `var_4` (the pointer used for the `var_4 != 0` check and the +virtual call) is never shown being assigned in the decompiled output — it +is almost certainly `var_24.m_tag` after `GlyphList::InqGlyph` copies the +found `Glyph` into `var_24` via `Glyph::operator=`, but the copy-into-field +step is not visible in this rendering. This is exactly the kind of +"misleading compare idiom" the project's BN caveat warns about — flagged +rather than silently assumed. The surrounding evidence (struct layout, +vtable offset match below) makes this the only coherent reading, but it is +not a directly-visible assignment. + +### 1b. Screen XY → glyph index: `UIElement_Text::DeterminePositionFromXY` + +`UIElement_Text::DeterminePositionFromXY @ 0x004688F0`: + +```c +int80_t UIElement_Text::DeterminePositionFromXY(this, arg2 /*local x*/, arg3 /*local y*/) +{ + UIElement_Text::RecalculateGlyphList(this); // ensure wrapped-line layout is current + int32_t scrolledY = this->m_iScrollableY + arg3; // undo vertical scroll offset + uint32_t line = 0; + GlyphList::FindLineFromY(&this->m_glyphList, scrolledY, &line); // which wrapped line + uint32_t lineWidthPx = 0; + GlyphList::GetGlyphLineWidth(&this->m_glyphList, line, &lineWidthPx); // that line's pixel width + int32_t lineLocalX = (this->m_iScrollableX + arg2) + - UIElement_Text::CalcJustification(this, lineWidthPx, 1); // undo h-scroll + justification + uint32_t glyphIndex = 0; + GlyphList::FindPosFromLineAndPixels(&this->m_glyphList, line, lineLocalX, 1, &glyphIndex); + // clamp to end-of-text + return min(glyphIndex, this->m_glyphList.m_glyphList._num_elements); +} +``` + +Plain-language: convert the click's local (x, y) into a *scrolled* position +by adding back however far the text view has been scrolled; use the +scrolled Y to find which **wrapped display line** was clicked +(`GlyphList::FindLineFromY @ 0x00472770`); measure that line's pixel width +(`GlyphList::GetGlyphLineWidth @ 0x00472930`) so the justification offset +(left/center/right alignment, `UIElement_Text::CalcJustification @ +0x00467260`) can be subtracted back out of the X; then walk that line's +glyph advances to find which **character index** the X pixel falls on +(`GlyphList::FindPosFromLineAndPixels @ 0x004732D0`). The result is a +single integer: "the click landed on/before character N of the full +(unwrapped) text buffer." + +### 1c. Glyph index → Glyph (and its tag): `GlyphList::InqGlyph` + +`GlyphList::InqGlyph @ 0x00473430`: + +```c +uint8_t GlyphList::InqGlyph(this, arg2 /*index*/, arg3 /*out Glyph*/) +{ + ListNode* node = this->m_glyphList._head; + if (node == 0 || arg2 >= this->m_glyphList._num_elements) return 0; + for (int i = 0; i != arg2; i++) { node = node->next; if (node == 0) return 0; } + Glyph::operator=(arg3, node); // copy the whole Glyph struct, incl. m_tag + return 1; +} +``` + +A straight O(n) linked-list walk (the glyph list is a `List`, not an +array) to the Nth glyph, then a struct copy. The `Glyph` layout +(`acclient.h:45330-45338`): + +```c +struct __cppobj Glyph +{ + unsigned __int16 m_data; // the character code + int m_width; + int m_height; + RGBAColor m_color; // per-glyph color, baked in at append time (see §4) + Font *m_font; + TextTag *m_tag; // non-null only for glyphs inside a <...> tag span +}; +``` + +`m_tag` is set by `Glyph::SetTag @ 0x00474920` while the glyph list is +built from raw text (see §1d), and cleared in bulk by +`GlyphList::RemoveTextTag @ 0x00472BB0` (walks every glyph, clears any +whose `m_tag` matches the tag being removed — used when a tagged span is +truncated/deleted from the log). + +### 1d. Building tags from markup: `TextTagFactory::MakeTag` + +Text is appended to a `UIElement_Text` glyph list via +`UIElement_Text::InqGlyphs @ 0x00468EA0`. Its char-scan loop treats `<` +(0x3C) as the start of a tag span: it accumulates characters up to the +matching `>` (0x3E) into a string, then calls +`TextTagFactory::MakeTag @ 0x00478480` on that whole inner string (e.g. +`"Tell:IIDString:1234:PlayerName"`): + +```c +TextTag* TextTagFactory::MakeTag(PStringBase const* tagBody) +{ + // tagBody looks like "TypeName:ShapeName:payload..." + if (!FindChar(tagBody, ':')) return 0; + typeNameStr = substring-before-first-colon; + if (EnumMapper::InqEnum(0x18 /*category*/, typeNameStr, &tagType) == 0) return 0; // e.g. "Tell" -> 0x10000001 + + if (!FindChar(rest, ':')) return 0; + shapeNameStr = substring-before-second-colon; // e.g. "IIDString" + if (EnumMapper::InqEnum(0x18, shapeNameStr, &shapeId) == 0) return 0; // 1..4 + + switch (shapeId) { + case 1: result = new TextTag_DID(); break; + case 2: result = new TextTag_IID(); break; + case 3: result = new TextTag_IIDEnum(); break; + case 4: result = new TextTag_IIDString(); break; + } + result->m_type = tagType; // from the FIRST lookup ("Tell" -> 0x10000001) + result->m_format = shapeId; + result->ParseStartTag(remaining payload); // shape-specific: fills TextTag_IIDString::m_IID/m_string, etc. + return result; +} +``` + +(Full disassembly at `docs/research/named-retail/acclient_2013_pseudo_c.txt:132871-133065`; +the two-colon split and the two `EnumMapper::InqEnum(..., 0x18, ...)` calls +are visible at `0x004784E6-0x00478545` and `0x00478577-0x004785DA`.) The +resulting `TextTag*` is stashed on every `Glyph` inside the span via +`Glyph::SetTag @ 0x00474920` as `InqGlyphs` walks the display characters +between the tag's `>` and its closing `<\...>`. + +**UNKNOWN — needs a DAT/cdb dump**: `EnumMapper::InqEnum`'s category `0x18` +is a data-driven (DAT-resident, likely `client_portal.dat` StringTable/ +EnumMapper resource) name↔id table — the code only proves the *mechanism*, +not the full roster of type-name strings it accepts. We confirmed "Tell" +(→ `m_type == 0x10000001`) and "IIDString" (→ shape id 4, inferred — see +§ "Other tag types" below) from literal format strings elsewhere in the +binary, but did not independently dump the table itself. + +### 1e. Vtable-offset proof that `var_4` is the tag and `+0x14` is `HandleClick` + +The `TextTag` vtable layout, read straight from the four subclass vtable +dumps (`docs/research/named-retail/acclient_2013_pseudo_c.txt:959321-959368`): + +| offset | slot | `TextTag_DID` | `TextTag_IIDString` | `TextTag_IIDEnum` | `TextTag_IID` | +|---|---|---|---|---|---| +| 0x00 | `__vecDelDtor` | ✓ | ✓ | ✓ | ✓ | +| 0x04 | `ParseEndTag` | `TextTag::ParseEndTag` (shared) | shared | shared | shared | +| 0x08 | `ParseStartTag` | own | own | own | own | +| 0x0C | `BuildEndTag` | `TextTag::BuildEndTag` (shared) | shared | shared | shared | +| 0x10 | `BuildStartTag` | `TextTag::BuildStartTag` (shared) | shared | shared | shared | +| **0x14** | **`HandleClick`** | **own** | **own** | **own** | **own** | +| 0x18 | `BuildStartTagData` | own | own | own | own | + +`+0x14` is exactly `HandleClick`, confirming `MouseUp`'s +`*(uint32_t*)(*(uint32_t*)var_4 + 0x14)(arg4)` is `var_4->HandleClick(arg4)` +— a virtual call, i.e. this is dispatched **per concrete tag subclass**, +not a single hardcoded action. + +--- + +## 2. What fires on click: generic dispatch, not hardcoded to tells + +All four subclasses' `HandleClick` do the same shape of thing — forward to +a global "notice" (AC's internal pub/sub event system) carrying the tag's +type + payload, nothing else: + +```c +// TextTag_DID::HandleClick @ 0x00478740 +ECM_UI::SendNotice_TextTag_DIDClick(this->m_type, this->m_DID.id); // @ 0x006926D0 + +// TextTag_IID::HandleClick @ 0x00478E80 +ECM_UI::SendNotice_TextTag_IIDClick(this->m_type, this->m_IID); // @ 0x00692720 + +// TextTag_IIDEnum::HandleClick @ 0x00478B40 +ECM_UI::SendNotice_TextTag_IIDEnumClick(this->m_type, this->m_IID, this->m_enum); // @ 0x00692770 + +// TextTag_IIDString::HandleClick @ 0x00478840 +ECM_UI::SendNotice_TextTag_IIDStringClick(this->m_type, this->m_IID, &this->m_string); // @ 0x006927C0 +``` + +So the dispatch **is** generic — any listener can register for any of the +four notices and react to any `m_type`. In this build, though, only ONE of +the four notices has an actual (non-stub) listener anywhere in the client: + +- `gmMainChatUI::RecvNotice_TextTag_IIDStringClick @ 0x004CCE10` — real, + wired to chat's click-to-tell (see §3). +- `NoticeHandler::RecvNotice_TextTag_IIDEnumClick @ 0x006A0240` is declared + `__pure` — a pure-virtual stub, no base behavior, and no override for it + was found anywhere in this pass. +- No function definition for a real `RecvNotice_TextTag_DIDClick` or + `RecvNotice_TextTag_IIDClick` override exists anywhere in the pseudo-C + file either — every other hit for those names is vtable-slot noise (the + decompiler filling unresolved thunk slots with neighboring symbol names; + cross-checked, none are real function bodies with those signatures). + +**Conclusion**: the mechanism is generic (4 tag shapes × arbitrary +`m_type` values × arbitrary listeners), but in this Sept 2013 build only +the chat-log "clickable speaker name → start a tell" feature is actually +wired up end-to-end. `TextTag_DID`/`TextTag_IID`/`TextTag_IIDEnum` exist, +parse, and would dispatch correctly if clicked, but nothing in the client +reacts to their click notices — **UNKNOWN whether item links / URLs / +coordinates use these shapes in later builds or via server-composed text +we didn't grep for**; no evidence of them was found in this pass. + +### Other tag types found (full roster) + +| Class | Shape id (inferred) | Ctor | `HandleClick` | Real listener found? | +|---|---|---|---|---| +| `TextTag_DID` | 1 | `0x00478760` | `0x00478740` | No | +| `TextTag_IID` | 2 | `0x00478E60` | `0x00478E80` | No | +| `TextTag_IIDEnum` | 3 | `0x00478B20` | `0x00478B40` | No | +| `TextTag_IIDString` | 4 | `0x00478860` | `0x00478840` | **Yes** — `gmMainChatUI` | + +Shape-id-to-class mapping is inferred from the `switch(shapeId){case +1..4}` construction order in `MakeTag` (`0x00478632`/`0x004785E1`/ +`0x004785FC`/`0x00478617`) plus the fact that the only shape name we can +directly read from format strings ("IIDString") is used everywhere the +"Tell" markup appears, which always constructs `TextTag_IIDString`. This +is strong circumstantial evidence, not a direct read of the DAT enum +table — flagged per the "no guessing" rule. + +--- + +## 3. The prefill itself: `ChatInterface::StartTell` + +`gmMainChatUI::RecvNotice_TextTag_IIDStringClick @ 0x004CCE10` is the +registered listener for `SendNotice_TextTag_IIDStringClick`: + +```c +void gmMainChatUI::RecvNotice_TextTag_IIDStringClick(this, uint32_t type, uint32_t iid, PStringBase const* name) +{ + if (type == 0x10000001 && ChatInterface::IsTextEntryFocused(this) == 0) + ChatInterface::StartTell(this, name); +} +``` + +Two gates: (a) the tag's semantic type must be `0x10000001` — i.e. only +"Tell"-markup spans do anything on click, other `m_type` values on an +`IIDString` tag (if any exist) are silently ignored here; (b) the chat +**entry box must not already have keyboard focus** — if the player is +mid-sentence typing something else, clicking a name in the log does +nothing (`ChatInterface::IsTextEntryFocused @ 0x004F30A0`, which checks +`UIElementManager`'s active/focused element against `this->m_chatEntry`). +Note the tag's own `iid` payload (`arg3`/`m_IID`) is read into the +parameter list but **never used** by this handler — only the embedded name +string matters. + +`ChatInterface::StartTell @ 0x004F41F0`: + +```c +void ChatInterface::StartTell(this, PStringBase const* name) +{ + PStringBase text = Formatted(u"@tell %s, ", name); // note: trailing space after comma + this->m_chatEntry->vtable->Activate(); + this->m_chatEntry->vtable->TakeFocus(); // <-- keyboard focus moves to the chat entry + CM_UI::SendNotice_ToggleChatEntry(1); // <-- ensures the chat entry bar is shown + UIElement_Text::SetText(this->m_chatEntry, &text); + UIElement_Text::MoveCursorToPosition(this->m_chatEntry, /* length of `text` */); + UIElement_Text::ClearSelection(this->m_chatEntry); +} +``` + +So, precisely: + +- **Text placed**: `"@tell , "` — literal `@tell`, a space, + the name, a comma, and a **trailing space** (ready to type the message + body immediately). +- **Focus**: YES, explicitly changed. `Activate()` + `TakeFocus()` move + keyboard focus into the chat entry field, and + `CM_UI::SendNotice_ToggleChatEntry(1) @ 0x0047A200` broadcasts a notice + whose real handler, `ClientUISystem::RecvNotice_ToggleChatEntry @ + 0x00564200`, is what shows/expands the chat entry bar if it was + currently hidden (confirmed as the one non-stub override among many + vtable-slot look-alikes for that notice name). +- **Cursor**: placed at the end of the inserted text (right after the + trailing space), any prior selection cleared. +- Whatever the player had already typed into the box (if it wasn't + focused — see the focus gate above) is **replaced outright**, not + merged or prepended to. + +--- + +## 4. Hover behavior: color is static per-tag-type, not a hover effect + +`UIElement_Text::MouseMove @ 0x004695F0` was checked directly — it does +**no** glyph/tag lookup at all. It only handles active text-selection +dragging (`this->m_bitField & 0x40`) or falls back to the base +`UIElement::MouseMove`. `UIElement_Text::GetShouldBeMouseVisible @ +0x00467460` likewise only inspects `this->m_bitField & 5` (an +editable/selectable flag), not glyph tags. **No hover-triggered highlight, +brightening, or cursor-icon change tied to `Glyph::m_tag` was found +anywhere in this pass.** + +What *is* real, and is presumably what reads as "the name is green," is a +**static per-glyph color chosen at glyph-construction time**, based on +whether the glyph belongs to an active `0x10000001`-typed tag span. Inside +`UIElement_Text::InqGlyphs @ 0x00468EA0`, right after a tag span is +opened/continues: + +```c +// 0x00469084-0x0046908A, ebx_1 = the currently-active TextTag* for this glyph (0 if none) +if (ebx_1 == 0 || ebx_1->m_type != 0x10000001) + color = &this->m_curFontColor; // offset 0x6A4 on UIElement_Text +else + color = &this->m_curTagFontColor; // offset 0x6B8 on UIElement_Text +// ... color is then baked into the new Glyph's m_color field +``` + +`UIElement_Text`'s struct (`acclient.h:53392-53420`) confirms two distinct +color fields exist: `RGBAColor m_curFontColor;` and +`RGBAColor m_curTagFontColor;`, set via +`UIElement_Text::SetFontColorHelper(this, attrId, &field, colorIndex)` +with **different authored attribute ids** — `0x1B` for `m_curFontColor`, +`0x1D` for `m_curTagFontColor` (seen consistently at +`UIElement_Text::AppendTextWithFont @ 0x00469D70` and +`AppendStringInfoWithFont @ 0x00469DE0`). `SetFontColorHelper @ +0x00466AC0` treats its 4th argument as an **index into an authored color +array** (an `InqProperty`-backed attribute, not a raw RGBA value) — so +`FontColor` and `TagFontColor` are two independently-authored per-window +color tables (almost certainly LayoutDesc-driven, consistent with this +project's existing DAT-driven-UI findings), and can hold different colors +at the same index. That is the entire "green name" mechanism: **it's +baked into the glyph once, from data, when the text is appended — not +computed or changed on mouse hover.** + +`m_curTagFontColor` defaults to `RGBAColor_White` (`0x00468641`) unless a +window's layout overrides attribute `0x1D` — chat windows presumably do. + +**UNKNOWN — needs DAT/runtime inspection**: the actual authored RGBA +values (attribute `0x1D`'s color table) live in a LayoutDesc DAT resource, +not in code; not dumped in this pass. Cross-reference +`claude-memory/reference_retail_chat_colors.md` for retail chat-color +ground truth already captured via cdb. + +--- + +## 5. Is `SetReplyTextInChatBox` the click handler? No — keyboard macro only + +`ChatInterface::SetReplyTextInChatBox @ 0x004F4760` has exactly one +caller in the whole binary: `ChatInterface::HandleTextReplacements @ +0x004F50D0`, which tries three "quick reply" expanders in order: + +```c +void ChatInterface::HandleTextReplacements(this) +{ + if (this->m_chatEntry == 0) return; + if (!ChatInterface::SetReplyTextInChatBox(this)) // @ 0x004F4760 — replies to LAST TELLER + if (!ChatInterface::SetMonarchReplyTextInChatBox(this)) // @ 0x004F4B70 — replies to monarch + ChatInterface::SetPatronReplyTextInChatBox(this); // @ 0x004F4EA0 — replies to patron +} +``` + +`HandleTextReplacements` is itself called from exactly one place: +`ChatInterface::ListenToElementMessage @ 0x004F51C0`, `case 0x11` (i.e. +`idMessage == 0x12`), gated on `arg2->dwParam1 == 0x20` (ASCII space) and +the message originating from the chat entry element: + +```c +case 0x11: // idMessage == 0x12 + if (arg2->pElement == this->m_chatEntry /* decompiled as m_fCurrentOpacity, mis-attributed field */ + && arg2->dwParam1 == 0x20) + ChatInterface::HandleTextReplacements(this); + break; +``` + +Message id `0x12` is confirmed elsewhere as the "character typed" +broadcast: `UIElement_Text::CharacterHandler @ 0x00469B90` ends its +non-control-character path with +`UIElement::BroadcastElementMessage(this, 0x12, typedChar, 0) @ +0x00469CAF` — `dwParam1` carries the raw character code. So this whole +path only fires **while the player is typing into the chat entry box and +presses the SPACE bar**, and only after typing one of a small set of +recognized prefixes. + +`SetReplyTextInChatBox` itself: reads the entry's current (trimmed) text, +checks whether it starts with `/` or `@`, and if so, matches the +first-word substring against known shortcut prefixes (`"t"`/`"te"`-style +1–2 char abbreviations at `data_7c4c70`/`data_7c4c68`, and the literal +`u"reply "` at `0x004F4A04`). If matched, it replaces that recognized +prefix (leaving anything typed after it in place) with: + +```c +gmCCommunicationSystem::GetLastTellerName(...) @ 0x00589550 // whoever LAST sent YOU a tell +Formatted(u"@tell %hs,", lastTellerName) // note: NO trailing space, %hs = narrow string +UIElement_Text::SetText / MoveCursorToPosition / ClearSelection // @ 0x004F4ADC-0x004F4AFA +``` + +This is a **different string** from the click path's `"@tell %s, "` (no +trailing space here; `%hs` explicitly narrow-string-formats +`GetLastTellerName`'s `PStringBase*` return, vs. the click path's +already-wide `PStringBase` name) — a small but real +divergence between the two "start a tell" paths worth preserving if both +get ported. `SetMonarchReplyTextInChatBox @ 0x004F4B70` and +`SetPatronReplyTextInChatBox @ 0x004F4EA0` are structurally identical, +sourcing the name from `GetLastAtMonarchUserName`/`GetLastAtPatronUserName` +instead. + +**Verdict**: `SetReplyTextInChatBox` is a **keyboard-shortcut/text-macro +handler only** — triggered by typing a recognized prefix then a space in +the chat entry. It shares the "write `@tell Name,` into the entry" idea +with the click path but is a wholly separate call chain, keyed off "last +person who told me something" global state +(`gmCCommunicationSystem::SetLastTeller @ 0x005891A0`, +`SetLastTellerName @ 0x00589500`) rather than the specific name embedded +in the clicked chat line's tag. It is **not** invoked by, and does not +invoke, any part of the click-to-tag chain in §1–§3. + +A consequence worth flagging for the port: because the click path reads +the name baked into that *specific* chat line's tag, clicking an **old** +"X tells you" line further up the scrollback still starts a tell to X, +even if X is no longer whoever last told you something — whereas the +`/t `+space keyboard shortcut always resolves to the single global +"last teller," which could be a different person by then. + +--- + +## 6. Where the clickable markup comes from (bonus — answers "why does this reproduce on so many message types") + +The `:>displayText<\Tell>` span is built by +`sprintf`-style formatting at multiple sites, not just for direct tells. +Representative literals (some inline strings are BN-truncated at ~33 +chars, marked `…`): + +| Format string (verbatim where fully visible) | Address | Used for | +|---|---|---| +| `"%s<\Tell> tells you, \"%s\"\n"` | `data_7d0ec0 @ 0x007D0EC0` | direct tell received | +| `"%s<\Tell> says, \"%s\"\n"` | `data_7d0e60 @ 0x007D0E60` | local/say-range speech | +| `"[Fellowship] %s<\\Tell> says, \""` | `data_7d0cdc @ 0x007D0CDC` | fellowship chat | +| `"[Co-Vassals] %s<\\Tell> says, \""` | `data_7d0bfc @ 0x007D0BFC` | co-vassal chat | +| `"[Allegiance Broadcast] %s<\\Tell> says, \""` | `data_7d0c30 @ 0x007D0C30` | allegiance broadcast | +| `"Your patron %s<\\Tell> says to you, \""` | `data_7d0d10 @ 0x007D0D10` | patron chat | +| `"Your vassal %s<\\Tell> says to you, \""` | `data_7d0d4c @ 0x007D0D4C` | vassal chat | +| `"Your follower %s<\\Tell> says to you, \""` | `data_7d0ca0 @ 0x007D0CA0` | follower chat | +| `"[%ws] %ws<\Tell> says, \"%ws\""` | `data_7e83e8 @ 0x007E83E8` | generic named channel say (channel name in `[%ws]`) | + +Confirms the closing marker is a literal backslash `<\Tell>` in the raw +string data (not the HTML-style `` one might assume), and that the +non-direct-tell variants hardcode the `iid` field to `0` (the click +handler ignores `iid` anyway, so this has no functional effect — but it +means the tag's `m_IID` is meaningless/decorative for anything except +direct tells). The gating logic for direct tells +(`docs/research/named-retail/acclient_2013_pseudo_c.txt:382537-382553`, +`gmCCommunicationSystem`-adjacent code around `0x00571880`) only emits the +tagged, clickable form when the sender's id falls in the player-character +GUID range (`0x50000001`-`0x6FFFFFFF`); tells attributed to ids outside +that range fall back to the plain, non-clickable +`"%s tells you, \"%s\"\n"` format (`0x00571880` false branch) — so +non-player "tells" (system/GM broadcast-as-tell, etc.) are never +clickable. + +--- + +## Open gaps (explicitly not resolved here) + +- **UNKNOWN**: the full roster of `EnumMapper` category `0x18` type-name + strings (only "Tell" and, by strong inference, "IIDString" are + confirmed). A DAT dump or a cdb breakpoint on `EnumMapper::GetString` + with category `0x18` would enumerate the rest and settle whether item + links/URLs/coordinates exist as other `m_type` values on the same + `IIDString` shape, or as `DID`/`IID`/`IIDEnum` shapes instead. +- **UNKNOWN**: authored RGBA values behind `m_curFontColor`/ + `m_curTagFontColor` attribute ids `0x1B`/`0x1D` for the chat log window + specifically — lives in a LayoutDesc DAT resource, not code. +- **Confirmed absence, not a gap**: no hover-only visual/cursor change was + found for tagged glyphs in this build (`MouseMove`, + `GetShouldBeMouseVisible` both checked directly and neither look at + `Glyph::m_tag`). diff --git a/docs/research/2026-08-21-chat-texttag-model.md b/docs/research/2026-08-21-chat-texttag-model.md new file mode 100644 index 00000000..9ca1f655 --- /dev/null +++ b/docs/research/2026-08-21-chat-texttag-model.md @@ -0,0 +1,849 @@ +# Retail chat TextTag / glyph-tag model — data model, lifetime, colour rule + +Research-only. No source was modified for this document. All addresses are +from the Sept 2013 EoR build (`refs/acclient.pdb` / `acclient.exe` v11.4186, +CodeView GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`), as decompiled in +`docs/research/named-retail/acclient_2013_pseudo_c.txt` (pseudo-C) and +`docs/research/named-retail/acclient.h` (verbatim retail struct headers). +Every claim below cites `symbol @ 0xADDRESS`; anything not directly +observed in the decompile is marked **UNKNOWN**. + +This document explains the mechanism behind the retail behaviour: a +speaker's name inside a chat line renders in a different colour and is +clickable (click → prefill a tell to that person). The mechanism is a +**glyph-level tag model**, not per-line colouring. `UIElement_Text` +(the class backing chat log / most retail text widgets) keeps a list of +`Glyph` structs, one per character, and each `Glyph` optionally points at +a shared, reference-counted `TextTag` object. A **contiguous run of +glyphs sharing the same `TextTag*` pointer** is what gets the special +colour and the click behaviour — there is no separate "run" or "span" +object; identity is pointer equality on `Glyph::m_tag`, discovered by +linear walk every time it matters. + +--- + +## 1. The `TextTag` type family + +### 1.1 Struct layout + +``` +acclient.h:45358 +struct __cppobj TextTag : ReferenceCountTemplate<1048576,0> +{ + unsigned int m_type; + unsigned int m_format; +}; +``` + +`ReferenceCountTemplate<1048576,0>` (`acclient.h:7974`) is: + +``` +struct __cppobj ReferenceCountTemplate<1048576,0> +{ + ReferenceCountTemplate<1048576,0>Vtbl *vfptr; // +0x0 + unsigned int m_cRef; // +0x4 +}; +``` + +So on a live `TextTag`, `vfptr` is at `+0x0`, `m_cRef` at `+0x4`, +`m_type` at `+0x8`, `m_format` at `+0xc`. Those exact offsets are used +directly by the pseudo-C at several sites cited below (e.g. +`*(uint32_t*)((char*)result + 8) = var_14` for `m_type`, +`*(uint32_t*)((char*)ebx_1 + 8) != 0x10000001` for a runtime `m_type` +comparison), which cross-checks the struct layout against the header. + +`TextTagType` (the type of `m_type`/`m_format`) is only ever typedef'd: + +``` +acclient.h:62585 +typedef unsigned int TextTagType; +``` + +No named enum for it survived in the PDB (`grep`'d `TextTagType|TAG_TYPE| +eTextTag` across `acclient.h` returns only that one typedef line). See +§7 for what this means for the `0x10000001` sentinel. + +### 1.2 Four concrete subclasses — what identifies/distinguishes a tag + +``` +acclient.h:53947 struct __cppobj TextTag_IID : TextTag { unsigned int m_IID; }; +acclient.h:53953 struct __cppobj TextTag_IIDEnum : TextTag { unsigned int m_IID; unsigned int m_enum; }; +acclient.h:53960 struct __cppobj TextTag_IIDString : TextTag { unsigned int m_IID; PStringBase m_string; }; +acclient.h:53967 struct __cppobj TextTag_DID : TextTag { IDClass<_tagDataID,32,0> m_DID; }; +``` + +So a `TextTag` is a small, polymorphic, ref-counted "click payload" +object. Its identity as far as glyphs/runs are concerned is just its +**pointer value** (see §2). Its semantic identity — what clicking it +actually means — is carried by the concrete subclass's extra field(s): + +- `TextTag_DID` — wraps a `DataID` (a DAT-file object reference). +- `TextTag_IID` — wraps an `IID` (an in-world Instance ID, i.e. a live + object/creature/player's server-assigned id). +- `TextTag_IIDEnum` — an `IID` plus an `enum` (a secondary + small-integer qualifier). +- `TextTag_IIDString` — an `IID` plus a `PStringBase` + (a wide string) — **this is the shape used for a clickable player + name**: `m_IID` is the speaker's object id, `m_string` most plausibly + carries their display name for building the tell command (see §6). + +Each subclass overrides a fixed 7-slot vtable (`__vecDelDtor`, +`ParseEndTag`, `ParseStartTag`, `BuildEndTag`, `BuildStartTag`, +`HandleClick`, `BuildStartTagData` — confirmed layout dumped verbatim at +`acclient_2013_pseudo_c.txt:959321-959375`, e.g. `TextTag_DID::`vftable'' +@ `0x0079e09c`). + +**Decompiler artifact to flag**: in the vtable dump, `TextTag_IID`'s +`ParseStartTag`/`BuildStartTagData` slots point at +`TextTag_DID::ParseStartTag` / `TextTag_DID::BuildStartTagData` +(`acclient_2013_pseudo_c.txt:959363,959367`), not at distinct +`TextTag_IID::*` functions. This is almost certainly MSVC identical-code +folding (COMDAT folding) — `TextTag_IID`'s parse/build logic for a bare +32-bit `m_IID` is byte-identical to `TextTag_DID`'s for a bare 32-bit +`m_DID.id`, so the linker merged them and the PDB can only attribute the +merged function to one of the two symbols. Treat this as "same code, +shared by both classes," not "IID delegates to DID." + +### 1.3 Factory / parsing — `TextTagFactory::MakeTag` + +``` +acclient_2013_pseudo_c.txt:132871 +00478480 class TextTag* TextTagFactory::MakeTag(class PStringBase const* arg1) +``` + +Given the text between `<` and `>` (the delimiters are stripped by the +caller — see §3), `MakeTag`: + +1. Finds the first `:` in the substring (`FindChar(':')`, + `acclient_2013_pseudo_c.txt:132904`). If none is found, parsing fails + and `MakeTag` returns `0` (`return 0;` @ `0x478700`, + `acclient_2013_pseudo_c.txt:133064`). **This is the mechanism an "end + tag" uses to close a run — see §3.2.** +2. Resolves the substring before the first `:` to an enum value via + `EnumMapper::InqEnum` (`acclient_2013_pseudo_c.txt:132921`). +3. Finds the *second* `:` and resolves that substring to a second enum + value, also via `EnumMapper::InqEnum` + (`acclient_2013_pseudo_c.txt:132954`), then `switch`es on it + (`acclient_2013_pseudo_c.txt:132955`) to allocate one of the four + concrete subclasses: + +``` +acclient_2013_pseudo_c.txt:132955-133051 (paraphrased switch table) +case 1: result = TextTag_DID::TextTag_DID(...) +case 2: result = TextTag_IID::TextTag_IID(...) +case 3: result = TextTag_IIDEnum::TextTag_IIDEnum(...) +case 4: result = TextTag_IIDString::TextTag_IIDString(...) +``` + +4. Stores the two resolved enum values into the new object: + +``` +acclient_2013_pseudo_c.txt:132981-132982 +*(int32_t*)((char*)result + 8) = var_14; // m_type = first EnumMapper::InqEnum result +*(int32_t*)((char*)result + 0xc) = var_18; // m_format = second EnumMapper::InqEnum result (== the switch discriminant, 1-4) +``` + +5. Delegates the remainder of the string (after the second `:`) to the + new object's own `ParseStartTag` virtual (via the vtable, at + `acclient_2013_pseudo_c.txt:133008`, + `*(int32_t*)((char*)vtable + 8)(__return)` — vtable slot `+0x8` = + `ParseStartTag` per the layout in §1.1) to consume the + type-specific payload (the `IID`/`DID`/`enum`/`string` fields). + If that fails, the freshly-allocated tag is released + (`ReferenceCountTemplate<1048576,0>::Release(result)` @ + `acclient_2013_pseudo_c.txt:133016`) and no tag is produced for this + span. + +So the overall wire format `MakeTag` parses is +**`TYPE_NAME:FORMAT_NAME:PAYLOAD`**, where `TYPE_NAME` selects `m_type` +(a semantic category — see §7) and `FORMAT_NAME` selects which concrete +subclass parses `PAYLOAD` (`m_format` doubles as "which of the four +built-in payload shapes this is"). + +Round-trip confirmation comes from `TextTag::BuildStartTag` (the +inverse operation, used when a tagged region is serialized back to +text — see §2.3): + +``` +acclient_2013_pseudo_c.txt:133619-133669 (TextTag::BuildStartTag @ 0x478fe0) +eax_1 = EnumMapper::InqString(0x18, this->m_type, &...); // name for m_type +eax_6 = EnumMapper::InqString(0x18, this->m_format, &...); // name for m_format +this->vtable->BuildStartTagData(&...); // subclass-specific payload text +PStringBase::sprintf(arg2, u"<%ls:%ls%:%ls>"); +``` + +(`u"<%ls:%ls%:%ls>"` — the stray `%` immediately after the second `%ls` +is very likely a Binary Ninja string-literal rendering artifact, not a +real extra `%` in the format string; the surrounding logic only ever +supplies three substitutions. **Flagged as uncertain** — resolving it +precisely would need a raw byte dump of the `.rdata` string at its +address rather than BN's decompiled string preview.) + +`EnumMapper::InqString`/`InqEnum` both route through a *table id* +argument of `0x18` (`acclient_2013_pseudo_c.txt:133627,133636,133725, +133364`, and `EnumMapper::InqEnum`'s call site in `MakeTag` at +`acclient_2013_pseudo_c.txt:132918-132921`). This table id is what +selects which named-enum table (`m_type`'s table vs. individual +subclass fields' tables) to search — see §7 for why we can't yet name +what string maps to `m_type == 0x10000001`. + +--- + +## 2. Attachment model — per-glyph, not per-run, not per-line + +### 2.1 `Glyph` struct + +``` +acclient.h:45330 +struct __cppobj Glyph +{ + unsigned __int16 m_data; // the character + int m_width; + int m_height; + RGBAColor m_color; // acclient.h:8100 — 4 floats (r,g,b,a), resolved per-glyph at append time + Font *m_font; + TextTag *m_tag; // nullable, shared, ref-counted +}; +``` + +`GlyphList` (`acclient.h:45305`) is a doubly-linked `List` plus a +cached `SmartArray` (line-break layout cache) and some +bookkeeping (`m_nMaxCharacters`, `m_nFirstInvalidPosition`, etc.). +`UIElement_Text` (`acclient.h:53392`) owns exactly one `GlyphList +m_glyphList` (the live/editable text) and a second `GlyphList +m_glTruncate` (used by the truncation machinery — not investigated +further here). + +**There is no `TextTag*` field, run/span object, or index range +anywhere on `GlyphList`, `GlyphLine`, or `UIElement_Text`.** The *only* +place a tag pointer lives is `Glyph::m_tag`, one per character. A +"tagged run" is purely an emergent property: a maximal sequence of +adjacent glyph list nodes whose `m_tag` fields are pointer-equal. + +### 2.2 How code discovers a run boundary + +Every place in the decompile that needs to know "does this edit split a +tagged run" or "did the tag change here" does the same thing: walk +adjacent glyphs and compare `data.m_tag` by pointer. Three load-bearing +examples: + +**On insert**, if the two glyphs immediately either side of the +insertion point shared a tag, the whole tag is stripped (see §3.1 for +why it's the *whole* tag, not just the boundary): + +``` +acclient_2013_pseudo_c.txt:127079-127090 (GlyphList::Insert @ 0x472e70) +class ListNode* prev = _current->prev; +if (prev != 0) +{ + class TextTag* m_tag = prev->data.m_tag; + if (m_tag == _current->data.m_tag) + GlyphList::RemoveTextTag(this, m_tag); +} +``` + +**On delete**, the boundary glyph of the doomed range is checked the +same way: + +``` +acclient_2013_pseudo_c.txt:127294-127302 (inside GlyphList::Delete @ 0x4730a0) +class TextTag* m_tag = edi->data.m_tag; +if (m_tag != 0) +{ + for (class ListNode* i = this_1->m_glyphList._head; i != 0; i = i->next) + { + if (i->data.m_tag == m_tag) + Glyph::SetTag(i, nullptr); + } +} +``` + +**On bulk append** (`GlyphList::AddText`), the glyph immediately before +the insertion point is compared to the first glyph being spliced in: + +``` +acclient_2013_pseudo_c.txt:127354-127362 (inside GlyphList::AddText @ 0x473190) +int32_t ebx = *(int32_t*)((char*)prev + 0x20); // prev->data.m_tag (offset +0x20 into Glyph) +if ((ebx == var_8->data.m_tag && ebx != 0)) +{ + for (class ListNode* i = this_2->m_glyphList._head; i != 0; i = i->next) + if (i->data.m_tag == ebx) + Glyph::SetTag(i, nullptr); +} +``` + +**On text serialization** (`GlyphList::InqText`, used to reconstruct +markup text — e.g. what `ChatInterface::TruncateChatLog` reads before +truncating, see §3.3), the same pointer-compare drives when to close +the previous tag's markup and open the new one: + +``` +acclient_2013_pseudo_c.txt:127671-127700 (inside GlyphList::InqText @ 0x473560) +class TextTag* m_tag = _head->data.m_tag; +if ((eax_4 != 0 && m_tag != m_tag_1)) // tag changed since previous glyph +{ + if (m_tag_1 != 0) + m_tag_1->vtable->BuildEndTag(&arg5); // close previous run's markup + if (m_tag != 0) + m_tag->vtable->BuildStartTag(&arg5); // open new run's markup +} +... +m_tag_1 = m_tag; // carried into next iteration +``` + +So: **a contiguous run is identified only by walking neighbours and +comparing `Glyph::m_tag` pointers; there is no cached run table.** +Every operation that could break a run's contiguity re-derives the +answer by walking. + +### 2.3 How a tag attaches during append — `UIElement_Text::InqGlyphs` + +``` +acclient_2013_pseudo_c.txt:115983 +00468ea0 uint8_t __stdcall UIElement_Text::InqGlyphs(class UIElement_Text* this @ ecx, class PStringBase const* arg2, class SmartArray* arg3) +``` + +This converts a raw wide string (which may contain embedded +`` markup) into a flat array of `Glyph`s, one +character at a time. It is called from `UIElement_Text::AddText_Internal` +(`acclient_2013_pseudo_c.txt:116791`), which is itself the single choke +point every text-append path funnels through (`AppendText`, +`AppendStringInfo`, `AppendStringInfoWithFont`, `CharacterHandler`, the +paste handler, etc. — confirmed by grepping every +`AddText_Internal(` call site, `acclient_2013_pseudo_c.txt:116886,116916, +116994,117008,117026,117043,117106`). + +The character loop keeps one local, `ebx_1` (`class TextTag*`), which is +the **currently-open tag while walking characters** — this is NOT a +field on `UIElement_Text`; it's a local in this one function's loop: + +- On seeing `<` (`0x3c`), it scans to the matching `>` (`0x3e`), + extracts the substring, and calls + `TextTagFactory::MakeTag(...)` (`acclient_2013_pseudo_c.txt:116117`). + The `<...>` delimiter text itself is **not emitted as glyphs** — the + character cursor (`edi_1`) is advanced past the closing `>` before + glyph emission resumes (`acclient_2013_pseudo_c.txt:116124-116131`). + The result — a new tag pointer, or `0` if `MakeTag` failed to parse — + replaces `ebx_1` for subsequent characters. +- For every ordinary character, the glyph's colour is chosen from `ebx_1` + (see §5 for the exact rule) and a `Glyph` is constructed carrying + `ebx_1` as its `m_tag` (§4 covers the ref-count mechanics of that + construction). + +**Because `ebx_1` is never explicitly reset to `0` on a "closing +bracket," the only way a tag run ends is for a later `<...>` span to +fail to parse into a valid tag** (no `:` found → `MakeTag` returns `0`, +§1.3 step 1). That is exactly what `TextTag::BuildEndTag` emits: + +``` +acclient_2013_pseudo_c.txt:133714-133748 (TextTag::BuildEndTag @ 0x479190) +if (this->m_type != 0) +{ + EnumMapper::InqString(0x18, this->m_type, &var_4); // just the type's name, no ':' + PStringBase::sprintf(arg2, u"<\%ls>"); + return 1; +} +return 0; +``` + +i.e. the end-tag markup is a bracketed **type name with no colon** +(`u"<\%ls>"` — the `\` immediately before `%` is almost certainly a +Binary Ninja rendering artifact for a literal `/`, i.e. the real string +is most plausibly `""`; **flagged as uncertain**, same caveat as +§1.3 — BN's string preview/escaping for embedded control characters is +not reliably faithful and this should be confirmed with a raw +`.rdata` byte dump before being relied on verbatim). Since that +substring has no `:`, `TextTagFactory::MakeTag`'s `FindChar(':')` check +fails and it returns `0` → `ebx_1` becomes `0` → every glyph after that +point is untagged, until the next successfully-parsed `` start tag. **Start/end tags are symmetric in markup shape but +asymmetric in mechanism**: a start tag is a successful `MakeTag` parse; +an end tag is nothing more than *any* bracketed text that fails to +parse as one. + +--- + +## 3. Lifetime + +### 3.1 Creation, retention, destruction — reference counting + +`TextTag` inherits `ReferenceCountTemplate<1048576,0>` (`m_cRef` at +`+0x4`, `vfptr` at `+0x0`). `TextTagFactory::MakeTag` hands back an +object with `m_cRef == 1` (set in the base ctor, +`acclient_2013_pseudo_c.txt:133587-133594`, +`TextTag::TextTag @ 0x478f80`: `this->m_cRef = 1;`). From there, +ownership is **fully distributed across every `Glyph` that points at +it** — there is no separate owning list or registry. + +**Adopting a tag reference increments the count.** The parameterized +`Glyph` constructor used by `InqGlyphs` when building a new glyph does +this explicitly: + +``` +acclient_2013_pseudo_c.txt:129088-129106 (Glyph::Glyph(this, char, color*, font*, tag*) @ 0x474a90) +*(uint32_t*)((char*)this_1 + 0x20) = arg5; // this->m_tag = tag +Glyph::SetFont(this_1, arg4); +int32_t eax_5 = *(uint32_t*)((char*)this_1 + 0x20); +if (eax_5 != 0) + InterlockedIncrement((eax_5 + 4)); // tag->m_cRef++ +``` + +The copy assignment operator (used whenever a glyph is copied — e.g. +splicing the freshly-built `SmartArray` from `InqGlyphs` into +the live `List`, or `List::flush`'s per-node teardown) +does the matching release-then-acquire: + +``` +acclient_2013_pseudo_c.txt:128949-128969 (Glyph::operator= @ 0x474870) +class TextTag* m_tag_1 = this->m_tag; +if (m_tag_1 != 0) +{ + if (InterlockedDecrement(&m_tag_1->m_cRef) == 0 && m_tag_1 != 0) + m_tag_1->vtable->__vecDelDtor(1); // release old tag, free at zero + this->m_tag = nullptr; +} +class Font* m_font = arg2->m_font; +this->m_font = m_font; +this->m_tag = arg2->m_tag; +if (this->m_tag != 0) + InterlockedIncrement(&this->m_tag->m_cRef); // acquire new tag +``` + +**Releasing decrements the count and self-deletes at zero, via the +destructor**: + +``` +acclient_2013_pseudo_c.txt:128905-128925 (Glyph::~Glyph @ 0x474820) +class TextTag* m_tag = this->m_tag; +if (m_tag != 0) +{ + if (InterlockedDecrement(&m_tag->m_cRef) == 0 && m_tag != 0) + m_tag->vtable->__vecDelDtor(1); + this->m_tag = nullptr; +} +``` + +**`Glyph::SetTag` is the exception to note carefully** — it releases the +*old* tag (decrement, free at zero) but does **not** increment the +refcount of the incoming tag: + +``` +acclient_2013_pseudo_c.txt:128977-128993 (Glyph::SetTag @ 0x474920) +void Glyph::SetTag(class Glyph* this, class TextTag* arg2) +{ + class TextTag* m_tag = this->m_tag; + if (m_tag == 0) + { + this->m_tag = arg2; + return; + } + if (InterlockedDecrement(&m_tag->m_cRef) == 0 && m_tag != 0) + m_tag->vtable->__vecDelDtor(1); + this->m_tag = nullptr; + this->m_tag = arg2; +} +``` + +Every call site of `Glyph::SetTag` actually observed in this decompile +passes `nullptr` for `arg2` (§2.2's three excerpts, plus the identical +pattern at `acclient_2013_pseudo_c.txt:126830`, +`GlyphList::RemoveTextTag`). In practice `SetTag` is only ever used as +"sever this glyph's reference to whatever tag it has" — a porting +engineer must not assume the general two-argument form is +refcount-safe for a non-null argument; if a call site with a non-null +tag is ever found, it must AddRef beforehand, mirroring the +constructor/`operator=` pattern above. + +### 3.2 Whole-tag invalidation on any edit that could split a run + +This is the single most important porting gotcha in this document. +None of the three "does this edit touch a tag boundary" checks in §2.2 +try to *split* a run in two. All of them, on detecting that an edit +would break contiguity, call `Glyph::SetTag(i, nullptr)` on **every +glyph in the entire `GlyphList` that shares that tag pointer** — not +just the glyphs adjacent to the edit. See `GlyphList::RemoveTextTag`: + +``` +acclient_2013_pseudo_c.txt:126822-126833 +void __thiscall GlyphList::RemoveTextTag(class GlyphList* this, class TextTag* arg2) +{ + if (arg2 != 0) + { + for (class ListNode* i = this->m_glyphList._head; i != 0; i = i->next) + { + if (i->data.m_tag == arg2) + Glyph::SetTag(i, nullptr); + } + } +} +``` + +`GlyphList::Insert`'s boundary check (§2.2) calls exactly this function +when it detects a would-be-split. `GlyphList::Delete` and +`GlyphList::AddText` inline the identical "walk the whole list, clear +every glyph sharing this tag" loop rather than calling +`RemoveTextTag` directly, but the effect is the same. **The retail +behaviour is: any edit that would leave a discontiguous run under one +tag pointer instead destroys the tag for the ENTIRE list, not just the +disturbed portion.** A tagged player name that gets partially edited or +partially deleted loses its colour/clickability everywhere it appears +in that `GlyphList`, not just at the edit site. + +### 3.3 Scroll-off / truncation — `ChatInterface::TruncateChatLog` + +``` +acclient_2013_pseudo_c.txt:247098 +004f4290 void __fastcall ChatInterface::TruncateChatLog(class ChatInterface* this, uint32_t arg2) +``` + +This reads the chat log's current text length (via +`UIElement_Text::GetText`, which is backed by `GlyphList::InqText`, §2.2) +and, if it exceeds the cap (`arg2`), calls: + +``` +acclient_2013_pseudo_c.txt:247148,247178 +UIElement_Text::BeheadText(this->m_chatLog, N, 1); +``` + +`BeheadText` is a thin wrapper: + +``` +acclient_2013_pseudo_c.txt:116727-116731 (UIElement_Text::BeheadText @ 0x469970) +void __thiscall UIElement_Text::BeheadText(class UIElement_Text* this, uint32_t arg2, uint8_t arg3) +{ + UIElement_Text::DeleteSection(this, 0, arg2, arg3); +} +``` + +which in turn calls `GlyphList::Delete` (`acclient_2013_pseudo_c.txt: +116680`, inside `UIElement_Text::DeleteSection @ 0x469800`) — **the +exact same generic deletion path used for any other text edit** (typed +backspace, cut, selection delete). There is no special-cased "truncate +the chat log" tag handling. Consequences, following directly from §3.1 +and §3.2: + +- A `TextTag` whose glyphs are entirely scrolled off is destroyed the + ordinary way: `GlyphList::Delete` walks the doomed range, finds the + shared tag, `Glyph::SetTag(..., nullptr)`s every glyph that shares it + (§3.2), decrementing to zero and freeing it. +- A `TextTag` whose glyphs are only **partially** scrolled off (the + truncation boundary falls inside a tagged name) has the tag stripped + from **all** its glyphs, including the ones that remain visible — per + §3.2's whole-list behaviour. The surviving remnant of a truncated + tagged name renders and behaves as plain untagged text. This is a + concrete, verified retail behaviour, not a hypothesis — it falls + directly out of `GlyphList::Delete`'s implementation, which + `BeheadText`/`TruncateChatLog` invoke with no special-casing. + +--- + +## 4. Colour rule — property `0x1b` (font colour) vs. `0x1d` (tag font colour) + +### 4.1 `UIElement_Text`'s "current" state fields + +``` +acclient.h:53392-53420 (struct UIElement_Text, relevant fields with confirmed field order) +RGBAColor m_curFontColor; // used for the property "0x1b" value +Font *m_curFontObj; // used for the property "0x1a" value +RGBAColor m_curTagFontColor; // used for the property "0x1d" value +unsigned int m_curOutlineColor; +``` + +The field ORDER in the header (`m_curFontColor`, `m_curFontObj`, +`m_curTagFontColor` back to back) matches the raw offset arithmetic seen +at the glyph-construction site in `InqGlyphs` +(`acclient_2013_pseudo_c.txt:116153-116162`, this-relative offsets +`0x6a4` for `m_curFontColor` and `0x6b8` for `m_curTagFontColor`, a +`0x14`-byte gap = 16 bytes of `RGBAColor` + 4 bytes of the `Font*` +pointer in between) — this cross-check confirms the struct layout +against the pseudo-C's raw pointer math. + +### 4.2 Setting them per append — `UIElement_Text::AppendStringInfoWithFont` + +``` +acclient_2013_pseudo_c.txt:117031-117048 +00469de0 void __thiscall UIElement_Text::AppendStringInfoWithFont(class UIElement_Text* this, class StringInfo const* arg2, int32_t arg3, int32_t arg4) +{ + UIElement_Text::SetFontDIDHelper(this, 0x1a, &this->m_curFontObj, arg3); + UIElement_Text::SetFontColorHelper(this, 0x1b, &this->m_curFontColor, arg4); + UIElement_Text::SetFontColorHelper(this, 0x1d, &this->m_curTagFontColor, arg4); + ... + UIElement_Text::AddText_Internal(this, m_charbuffer, 3); + ... +} +``` + +Both colour properties are refreshed from the **same caller-supplied +index**, `arg4`, immediately before the string is appended +(`AddText_Internal` → `InqGlyphs`, §2.3). The same three-call pattern +(`0x1a`/`0x1b`/`0x1d`, same index argument) recurs at every other append +entry point that carries a colour index: +`UIElement_Text::AppendText`/`AppendStringInfo`'s shared helper at +`acclient_2013_pseudo_c.txt:115213-115222`, the string-download +completion handler at `acclient_2013_pseudo_c.txt:117098-117104` (index +taken from the queued download's own stored index, +`ebx_2[0x25]`/`ebx_2[0x26]`), and the reset-to-index-0 call at +`acclient_2013_pseudo_c.txt:117546-117548`. + +### 4.3 What `SetFontColorHelper` actually does — an indexed property array + +``` +acclient_2013_pseudo_c.txt:113699 (UIElement_Text::SetFontColorHelper @ 0x466ac0) +void __thiscall UIElement_Text::SetFontColorHelper(class UIElement_Text* this, uint32_t arg2 /*propId*/, class RGBAColor* arg3 /*out*/, uint32_t arg4 /*index*/) +``` + +Simplified control flow (BN's exact vtable-offset dispatch on `0xf0`, +`0xf4`, `0x98` is not named by the PDB — see caveat below): + +1. `this->vtable->InqProperty(propId, &var_10)` — looks up the + UIElement's own authored property (`0x1b` or `0x1d`) via its normal + `UIElement` property mechanism, i.e. this is a + **LayoutDesc/DAT-authored per-element property**, not global engine + state. +2. If found, the returned property object is treated as an *indexed + collection*: a virtual call through vtable offset `+0xf0` + (`acclient_2013_pseudo_c.txt:113743`) that plausibly returns the + collection's element count into `arg2`; a bounds check + `if (arg4 < arg2)`; then a virtual call through `+0xf4` + (`acclient_2013_pseudo_c.txt:113758`) that plausibly fetches the + sub-property at index `arg4`; then a virtual call through `+0x98` + (`acclient_2013_pseudo_c.txt:113761`) that plausibly extracts an + `RGBAColor` from that sub-property into the caller's `arg3` output. +3. If any step fails (property absent, index out of range), `arg3` (the + caller's `m_curFontColor`/`m_curTagFontColor`) is left untouched — + i.e. it retains whatever colour it already held from a previous + append. + +**Caveat**: BN does not resolve names for the `+0xf0`/`+0xf4`/`+0x98` +virtual calls (they're dispatched through a generic `BaseProperty`-family +vtable, and the pseudo-C prints them as raw `(*(uint32_t*)(vtable + +offset))(...)` calls). The functional interpretation above +("count / get-at-index / get-color") is inferred from the argument +shapes and control flow (an `arg4 < count` bounds check immediately +followed by an index-parameterized fetch), not from a symbol. Treat the +step-by-step mechanics as **probable, not certain** — the porting-load- +bearing fact that IS certain is the *outcome*: property `0x1b` and +property `0x1d` are each an array of colours on the `UIElement_Text`, +indexed by the same `arg4` the caller supplies, and `SetFontColorHelper` +resolves one colour from each array into `m_curFontColor` / +`m_curTagFontColor` respectively before the string is walked into +glyphs. This lines up with `claude-memory/project_chat_digest.md`'s +`LogTextType colors` note — `arg4` is almost certainly the `LogTextType` +of the message being appended (a per-message-category colour index), +and property `0x1d` is a **parallel, per-category array of "tag" +colours** — i.e. retail authors one link/tag colour per chat category, +not one global link colour. + +### 4.4 Which colour a glyph actually gets — the `m_type == 0x10000001` gate + +Back in `UIElement_Text::InqGlyphs`, per character, the code picks +between the two "current" colours based on whether a tag is open **and** +that tag's `m_type` equals a specific sentinel: + +``` +acclient_2013_pseudo_c.txt:116150-116162 +void* edx_15; +void* esi_6; +if ((ebx_1 == 0 || *(uint32_t*)((char*)ebx_1 + 8) != 0x10000001)) +{ + esi_6 = esp_1[6]; // this (UIElement_Text*) + edx_15 = ((char*)esi_6 + 0x6a4); // &this->m_curFontColor +} +else +{ + esi_6 = esp_1[6]; + edx_15 = ((char*)esi_6 + 0x6b8); // &this->m_curTagFontColor +} +``` + +(`ebx_1 + 8` is `TextTag::m_type`, per the struct layout in §1.1.) +`edx_15` is then passed straight into the parameterized `Glyph` +constructor as the colour source (`acclient_2013_pseudo_c.txt:116173- +116180`). So, precisely: + +- No open tag (`ebx_1 == 0`) → glyph gets `m_curFontColor` (property + `0x1b`'s indexed value). +- Open tag, but its `m_type != 0x10000001` → **still** + `m_curFontColor`. Not every tag type gets the special colour. +- Open tag with `m_type == 0x10000001` → glyph gets `m_curTagFontColor` + (property `0x1d`'s indexed value). + +**This is the exact rule the task asked for**: property `0x1b` is the +default/base colour used for all untagged text and for any tag whose +type isn't the specially-recognized one; property `0x1d` is used only +for glyphs inside a tag of that one recognized type, and both are +selected from the same caller-supplied colour-category index. See §7 +for what is and is not known about what `0x10000001` names. + +### 4.5 "Currently open tag" state + +There is **no persistent "currently open tag" field on `UIElement_Text`** +— `m_curFontColor`/`m_curFontObj`/`m_curTagFontColor` are the *colour +palette currently in effect for this append call* (refreshed once per +`AppendStringInfoWithFont`/`AppendText` call from the indexed DAT +properties), not per-tag state. The actual "is a tag open right now, +and which one" state during the character walk is the **local variable +`ebx_1` inside `UIElement_Text::InqGlyphs`'s loop** (§2.3) — it does not +outlive one call to `InqGlyphs`/`AddText_Internal`. Each append call +starts fresh with no tag open, and the markup embedded in that call's +own string is what opens/closes tags within it. + +--- + +## 5. Click dispatch + +Each concrete subclass's `HandleClick` (vtable slot `+0x14`, per §1.2) +forwards to a `ECM_UI::SendNotice_TextTag_*Click` free function, which — +per the `NoticeHandler` vtable declared in `acclient.h:30237-30240` — is +a broadcast notice any registered `NoticeHandler` (e.g. the chat/social +UI) can receive via a matching `RecvNotice_TextTag_*Click` virtual: + +``` +acclient_2013_pseudo_c.txt:133078-133085 (TextTag_DID::HandleClick @ 0x478740) +ECM_UI::SendNotice_TextTag_DIDClick(this->m_type, this->m_DID.id); + +acclient_2013_pseudo_c.txt:133521-133528 (TextTag_IID::HandleClick @ 0x478e80) +ECM_UI::SendNotice_TextTag_IIDClick(this->m_type, this->m_IID); + +acclient_2013_pseudo_c.txt:133328-133335 (TextTag_IIDEnum::HandleClick @ 0x478b40) +ECM_UI::SendNotice_TextTag_IIDEnumClick(this->m_type, this->m_IID, this->m_enum); + +acclient_2013_pseudo_c.txt:133150-133157 (TextTag_IIDString::HandleClick @ 0x478840) +ECM_UI::SendNotice_TextTag_IIDStringClick(this->m_type, this->m_IID, &this->m_string); +``` + +matching the `NoticeHandler` vtable slots: + +``` +acclient.h:30237-30240 +void (__thiscall *RecvNotice_TextTag_DIDClick)(NoticeHandler *this, unsigned int, IDClass<_tagDataID,32,0>); +void (__thiscall *RecvNotice_TextTag_IIDClick)(NoticeHandler *this, unsigned int, unsigned int); +void (__thiscall *RecvNotice_TextTag_IIDEnumClick)(NoticeHandler *this, unsigned int, unsigned int, unsigned int); +void (__thiscall *RecvNotice_TextTag_IIDStringClick)(NoticeHandler *this, unsigned int, unsigned int, PStringBase *); +``` + +This strongly supports the observed behaviour ("clicking a speaker's +name in chat prefills a tell to them"): a chat name is plausibly tagged +`TextTag_IIDString`, carrying the speaker's `IID` (their in-world +object id — the correct addressee for a `/t` tell) and `m_string` +(plausibly the speaker's display name — needed because the retail tell +command syntax is name-based, not id-based). Clicking dispatches +`ECM_UI::SendNotice_TextTag_IIDStringClick(type, IID, &name)`, and +whichever `NoticeHandler` owns the chat input box (this document did +not trace that far — **UNKNOWN, needs a search of `RecvNotice_TextTag_ +IIDStringClick` overrides across the UI classes to find which panel +consumes it and confirm it prefills `/t "name" `**) reacts by loading +that into the input field. + +**Not traced from HandleClick backward to the mouse-hit-test that finds +"which glyph, hence which tag, is under the cursor."** No literal +`->vtable->HandleClick(...)` call site was found via text grep (it's an +indirect vtable call, invisible to a literal-string search); locating +the exact hit-test function that resolves a click's screen position to +a glyph index and reads that glyph's `m_tag` was out of scope for this +pass. **UNKNOWN — needs a targeted search for the `UIElement_Text` +mouse-down handler** (candidates: something built on +`GlyphList::FindPosFromLineAndPixels` @ +`acclient_2013_pseudo_c.txt:127424`, which is already known to resolve +screen pixels to a glyph index and is a very likely component of that +path, but the actual click-to-`HandleClick` wiring was not confirmed). + +--- + +## 6. Summary — the model to port + +1. **Data model**: `TextTag` is a small polymorphic ref-counted object + (`m_type`, `m_format`, plus subclass payload — `IID`/`DID`/`enum`/ + `string` in various combinations). `Glyph` carries an OPTIONAL + `TextTag*`. `GlyphList` is a flat list of `Glyph`; nothing above the + glyph level stores tag/run information. +2. **Attachment**: identity of a "run" is pointer equality on + consecutive glyphs' `m_tag`. No cached run table exists; every + consumer (insert-boundary check, delete-boundary check, append- + boundary check, text-serialization) re-derives it by walking + neighbours. +3. **Markup**: `` opens a tag (parsed by + `TextTagFactory::MakeTag`, dispatching on the `FORMAT` value to one + of 4 concrete classes); any bracketed text that fails to parse (in + particular the literal ``-shaped close marker emitted by + `TextTag::BuildEndTag`) closes the currently-open tag. The delimiter + text itself is never rendered as glyphs. +4. **Lifetime**: pure intrusive refcounting. Adopting a tag reference + (construction, copy-assignment) increments; releasing (destruction, + explicit clear, reassignment) decrements and self-deletes at zero. + **Any edit that would split a tagged run instead strips the tag from + every glyph in the WHOLE `GlyphList` that shares it** — there is no + run-splitting. Chat-log truncation (`TruncateChatLog` → + `BeheadText` → `DeleteSection` → `GlyphList::Delete`) is not + special-cased; it goes through this exact same path, so a tagged + name straddling the truncation boundary loses its tag entirely, even + on the surviving portion. +5. **Colour**: two parallel, per-`UIElement_Text`, DAT-authored, + index-selected colour arrays — property `0x1b` (base/default) and + property `0x1d` (tag colour) — refreshed from the same caller + colour-category index at the top of every append call. A glyph gets + the `0x1d` colour only if a tag is open AND that tag's `m_type` + equals the sentinel `0x10000001`; otherwise it gets the `0x1b` + colour regardless of whether some *other* kind of tag is open. +6. **Click**: each concrete `TextTag` subclass's `HandleClick` + broadcasts a `NoticeHandler`-family notice + (`ECM_UI::SendNotice_TextTag_*Click`) carrying its payload; some + listener elsewhere (not traced in this pass) reacts to populate chat + input, open a character sheet, etc., depending on subclass/`m_type`. + +--- + +## 7. Open questions / explicitly unresolved + +- **What symbolic name does `TextTag::m_type == 0x10000001` correspond + to?** `TextTagType` has no recovered named enum (`acclient.h:62585` is + a bare typedef). The literal `0x10000001` recurs pervasively + elsewhere in the pseudo-C for apparently unrelated purposes (dialog + IDs, `StringInfo::SetStringIDandTableEnum` table-enum arguments, + keymap IDs — see the broad grep hits at + `acclient_2013_pseudo_c.txt:2334,135182,149121,150950,154810,...`), + which suggests it's a low, sequential "category 1" id reused across + several small per-subsystem enums rather than one global constant + with a single meaning — i.e. seeing the same literal elsewhere is + **not** evidence about what it means for `TextTag`. Both + `EnumMapper::InqEnum`/`InqString` route through a table id of `0x18` + (§1.3), which is very likely a DAT-resident enum/string table (the + lookup falls through `MasterDBMap::DivineType`-style DBObj resolution + seen in `EnumMapper::GetEnumByDID`, + `acclient_2013_pseudo_c.txt:29890-29937`, for other DID categories), + meaning the actual keyword strings (e.g. whatever text maps to + `m_type == 1`) live in a DAT string/enum table, not as a compiled + string literal — grepping for literal tag keywords like `"IID"`, + `"DID"` in the pseudo-C found nothing. **Needs**: pulling DAT category + `0x18`'s EnumMapper table contents (likely in + `client_local_English.dat` or `client_portal.dat`) to find the actual + keyword-to-`m_type` mapping, the same way + `claude-memory/project_settings_options_digest.md`'s `GetNameFromKey` + work pulled DAT tables `0x2300000A`/`0x2300000B`/`0x23000007`. +- **Exact wording of the two `sprintf` format strings** at + `acclient_2013_pseudo_c.txt:133651` (`u"<%ls:%ls%:%ls>"`) and + `acclient_2013_pseudo_c.txt:133728` (`u"<\%ls>"`). Binary Ninja's + string-literal rendering is known to mis-escape embedded + slashes/percents in this codebase; both are flagged inline in §1.3/§2.3 + as probable artifacts (a stray `%` in the first, a `\` that's likely a + literal `/` in the second). **Needs**: a raw byte dump of the two + `.rdata` string constants at their addresses (not BN's decompiled + preview) to confirm exact bytes before porting the exact markup + syntax. +- **The exact semantics of `SetFontColorHelper`'s three virtual calls** + (vtable offsets `+0xf0`, `+0xf4`, `+0x98`, §4.3) are inferred from + control flow, not named by the PDB. The functional summary (indexed + colour array) is believed correct, but the precise interface + (`BaseProperty`'s exact virtual table) was not independently + confirmed against `acclient.h`'s `BaseProperty`-family struct + definitions in this pass. +- **The mouse-hit-test → `HandleClick` wiring** (§5) — which function + resolves a click position to a glyph, reads its `m_tag`, and invokes + `HandleClick` through the vtable — was not located in this pass (no + literal-text call site exists to grep for an indirect vtable call). +- **Which `NoticeHandler` override actually consumes + `RecvNotice_TextTag_IIDStringClick` and prefills the chat input** — + not traced. This is the last link needed to fully confirm "clicking a + chat name populates a `/t` tell," though the `IID` + name payload + shape on `TextTag_IIDString` makes it the overwhelmingly likely + candidate tag type for that UI behaviour. diff --git a/docs/research/2026-08-21-retail-chat-window-ui.md b/docs/research/2026-08-21-retail-chat-window-ui.md new file mode 100644 index 00000000..152d44d7 --- /dev/null +++ b/docs/research/2026-08-21-retail-chat-window-ui.md @@ -0,0 +1,650 @@ +# Retail chat WINDOW shell — window model, filters, scrollback, chrome + +**Date:** 2026-08-21 +**Status:** RESEARCH ONLY. No source files touched. +**Scope:** retail's chat window SHELL and DISPLAY behavior — window +management, filtering, scrollback, chrome/interaction, multi-window, +line-composition structure, and other user-visible window mechanics. +**Explicitly out of scope** (covered by sibling research this session): +glyph text-tag coloring, clickable/colored names, tag click dispatch, and +acdream's own current UI code. This document does not re-derive anything +already answered there. + +**Primary sources** +- `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR + build, Binary Ninja pseudo-C, PDB-named) +- `docs/research/named-retail/acclient.h` (verbatim retail struct/enum defs) +- `docs/research/named-retail/symbols.json` + +**Notes read first so this extends rather than repeats:** +- `docs/research/2026-08-09-chat-retail-window-shell.md` (CH6 shell research + — window lifecycle/identity, LayoutDesc geometry, resize model, opacity, + persistence, multi-window/floaty mechanics). **This document is the + authority for §1 window-lifecycle mechanics, §4 chrome/resize/opacity, and + §5 tabs/multi-window — I only summarize its findings below with pointers, + and add what it doesn't cover:** scrollback/truncation, the exact + window-ID routing predicate as a single decompiled function, structural + line-composition order, and the unseen-text/auto-scroll interaction. +- `docs/research/2026-08-09-chat-retail-color-table.md` §4 (filter storage, + `m_llTextTypeFilter`, `PostInit` seeded defaults) — I summarize and do not + re-derive; I use its findings to cross-check the routing function decoded + fresh below. +- `docs/plans/2026-08-09-chat-parity-campaign.md` — Campaign CH plan/ledger. + +**Binary-Ninja caveats (apply throughout, per +`claude-memory/feedback_bn_decomp_field_names.md`):** BN's struct-field +attribution in `ChatInterface::PostInit`/`gmMainChatUI::PostInit` is shifted +by one slot relative to the true member order — the window-shell doc already +documented this for the main window's border elements. I hit the same +artifact in `ChatInterface::PostInit`'s `GetChildRecursive` binding sequence +(§1) and resolve it the same way: against the verbatim struct order in +`acclient.h:54898-54912`, which is authoritative and does not shift. + +--- + +## 1. Window model + +### 1.1 How many windows, and how they're identified + +Confirmed against `acclient.h:54898-54912` (verbatim `ChatInterface` +struct): + +```cpp +/* 6041 */ +struct __cppobj ChatInterface : gmNoticeHandler, UIElement_Field +{ + unsigned int m_eWindowID; + float m_fDefaultOpacity; + float m_fActiveOpacity; + float m_fCurrentOpacity; + UIElement_Text *m_chatEntry; + UIElement_Text *m_chatLog; + UIElement *m_chatNewNonVisibleTextIndicator; + unsigned __int64 m_llTextTypeFilter; + UIElement_Text *m_pChatTargetButtonText; + PStringBaseArray m_InputHistory; + unsigned int m_LastInputHistoryPos; + ClientCommunicationSystem *m_pCCS; +}; +``` + +Per the window-shell doc §1.2/§4.1 (not re-derived here): **five** live +chat windows exist — the main window (`m_eWindowID == 8`) and four floating +windows (`m_eWindowID == 2..5`). `m_eWindowID == 0` is the **UNAUTHORED +constructor default** (`ChatInterface::ChatInterface @0x004F4550` sets +`this->m_eWindowID = 0;` before `PostInit` reads the real value off the +LayoutDesc attribute `0x1000007E`). All five windows are authored, +always-resident children of the gameplay-UI root — there is no +runtime-allocated window registry (window-shell doc §1.1). + +The SpewBox (`gmSpewBoxUI`) is a **separate, unrelated class** — not a +`ChatInterface` subclass, not part of this window-id space (per +`claude-memory/project_chat_digest.md`). + +### 1.2 The wire-to-window routing predicate — one function, load-bearing + +`ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640` is the single +function every displayed chat line passes through. Its **head** (the routing +decision, before any text is appended) is: + +``` +004f4640 void __thiscall ChatInterface::RecvNotice_DisplayFinalStringInfo( + class ChatInterface* this, uint32_t arg2 /*type*/, + class StringInfo const* arg3 /*body*/, + class StringInfo const* arg4 /*prefix*/, uint32_t arg5 /*windowId*/) +004f4640 { +004f4640 uint32_t eax_7 = arg5; +004f4652 if (eax_7 == this->m_eWindowID) +004f4652 { +004f467c label_4f467c: + … (appends — see §3/§6) … +004f4652 } +004f4652 else if ((eax_7 == 0 && ChatInterface::TypeIsActive(this, arg2) != 0)) +004f4666 goto label_4f467c; +004f4640 } +``` + +**The predicate is exactly: `windowId == m_eWindowID` OR (`windowId == 0` +AND `TypeIsActive(type)`).** This is an ADDRESS-vs-BROADCAST model, not a +"windows subscribe to a channel" model: + +- A line sent with a **specific windowId** (matching an already-open target + window, e.g. a command whose output is explicitly directed at the window + that issued it — `m_idCurrentCommandSource` per the color-table doc §4) + is shown **only** in that one window, unconditionally — the destination + window's own filter is never consulted for an address-targeted line. +- A line sent with **windowId == 0** ("broadcast") is shown in **every** + window whose own `TypeIsActive(type)` (i.e. its 64-bit + `m_llTextTypeFilter`, decoded in the color-table doc §4) says yes. This is + how the same "Sio says, ..." line can land in the main window and in a + floating window simultaneously if both have `Speech` enabled. +- **Window id 0 is therefore never itself a *window* — it is the broadcast + sentinel value on the wire/call parameter, exactly as the window-shell + doc's goal-window addendum states.** No live `ChatInterface` instance ever + keeps `m_eWindowID == 0` after `PostInit` runs. + +`ChatInterface::TypeIsActive @0x004F2F10` (cited, not re-derived, per the +color-table doc §4) is `(1ULL << type) & m_llTextTypeFilter`. + +--- + +## 2. Filters + +**Fully decoded already in `2026-08-09-chat-retail-color-table.md` §4 — not +re-derived here.** Summary for completeness of this document's structure: + +- Storage: 64-bit `ChatInterface::m_llTextTypeFilter` (`acclient.h:54907`), + read from `PlayerModule::InqChatWindowOption(windowId, 0x1000007F, …)` + (`ChatInterface::UpdateFromPlayerModule @0x004F3920`) and live-updated via + `RecvNotice_GameplayOptionChanged @0x004F30E0`. +- Test: `ChatInterface::TypeIsActive @0x004F2F10` — `(1ULL << type) & + m_llTextTypeFilter`, used both for the broadcast-routing predicate (§1.2) + and, per the color-table doc, nowhere else. +- **`PostInit`'s per-window seeded default** switches on `m_oldState` + (`ChatInterface::PostInit @0x004F3DD0`, `0x004f3df9`): 1 and 8 (the main + window) get `0xFBFFFFFF` low-dword (everything except client-local `0x1A`); + 2 (floaty 1) gets Speech/Tell/Speech_Direct_Send/Emote; 3 (floaty 2) gets + Social/Social_Send/Allegiance; 4 (floaty 3) gets Fellowship; 5 (floaty 4) + gets the four Turbine rooms General/Trade/LFG/Roleplay. Every default's + HIGH dword is 0 — Society (`0x20`) and the reserved `0x21` slot start + disabled in **every** window and must be opted into by the user. +- User edit path: `gmChatOptionsUI::InitOptions @0x0049FC60` / + `AddCheckboxBitfield64Option @0x0049EDA0` build one checkbox-grid `SetUserData` + block per window id (main = id 8, with its own dedicated Society checkbox + child at `0x0049FEFB`). +- Squelching is a **separate axis** from filtering: + `LogTextTypeEnumMapper::IsLegalChannel @0x006AFF40` whitelists a 14-value + subset of `LogTextType` as squelchable at all; it has no interaction with + `m_llTextTypeFilter`. + +Nothing new to add here beyond what the color-table doc already covers — +the routing predicate decoded fresh in §1.2 above is a second, independent +confirmation of the same "`windowId==0` → filter-gated broadcast" model that +doc's §4 described from `RecvNotice_DisplayFinalStringInfo`'s citation +alone; this document supplies the full decompiled function body. + +--- + +## 3. Scrollback + +### 3.1 The cap, the trigger, and the trim target + +Still inside `RecvNotice_DisplayFinalStringInfo @0x004F4640`, immediately +after the body append (full excerpt with the append order in §6): + +``` +004f4701 int32_t m_chatLog_1 = this->m_chatLog; +004f4711 if (*(uint32_t*)(m_chatLog_1 + 0x61c) > 0x2710) +004f4711 { +004f4713 int32_t var_14_4 = 0x1d4c; +004f471a m_chatLog_1 = ChatInterface::TruncateChatLog(this, m_chatLog_1); +004f4711 } +``` + +`0x2710` = **10,000**, `0x1d4c` = **7,500**. The field read at transcript +offset `+0x61C` tracks the transcript's total **character** count (not a +line count) — retail's scrollback limit is a character budget, not a +fixed number of retained lines. **Trigger: transcript exceeds 10,000 +characters. Target: trim back down toward ~7,500.** This runs on every +appended line once the log is over budget — it is not a periodic/timed +sweep, it is inline in the same call that just displayed the line. + +### 3.2 The truncation rule — trims at a newline boundary, not mid-line + +`ChatInterface::TruncateChatLog @0x004F4290` (arg2 = target length, 7500 at +the only call site found): + +``` +004f4290 void __fastcall ChatInterface::TruncateChatLog(class ChatInterface* this, uint32_t arg2) +004f4290 { + text = GetText(m_chatLog); // live PStringBase + currentLen = text.length; // *(len_ptr - 4) +004f42c2 if (currentLen <= arg2) + return; // under budget — no-op +004f42c2 else + { +004f42c8 excess = currentLen - arg2; // chars over target + … PStringBaseIter_Common::FindChar(iter, "\n", 1) … + // search FORWARD from the excess offset for the next '\n' +004f4350 if (found && (excess - foundPos) < (currentLen / 10)) +004f4360 BeheadText(m_chatLog, foundPos + 1, 1); // cut at that newline + else { + … FindChar(iter, "\n", 0) … // search again, other direction arg +004f43f0 if (found2 && (foundPos2 - excess) < (currentLen / 10)) + goto (the same BeheadText-at-newline path) +004f4403 else + BeheadText(m_chatLog, excess, 1); // fallback: cut at the raw excess offset + } +004f4290 } +``` + +Reading this at the BN pseudo-C level is genuinely uncertain past the +overall shape — **flagging per the assignment's constraint rather than +guessing**: the `0xCCCCCCCD` multiply + `HIGHD(...) >> 3` pair is the +standard MSVC constant-division-by-10 idiom (`length / 10`), and the two +`FindChar` calls with a `PStringBase(&data_79c288)` needle (confirmed below, +§3.3, to be a single `\n` character) plus `UIElement_Text::BeheadText` are +unambiguous. **UNKNOWN — needs a live cdb capture with real transcript +content to nail down exactly:** whether the two `FindChar` calls search in +opposite directions from the excess offset (my reading above) or whether +one is a fallback re-search after the first's 10%-tolerance check fails for +a different reason; the two `arg3` values passed to `FindChar` (`1` then +`0`) are almost certainly a direction or "case-sensitive/whole-word" flag, +but the pseudo-C never names the parameter. **What is certain and +sufficient to port:** truncation removes text from the FRONT of the +transcript (`BeheadText`), it PREFERS a boundary within the char that +begins the next `\n`-terminated line rather than a raw char-offset cut +(there's a ~10%-of-current-length tolerance band around the target for +preferring the newline-aligned cut), and it falls back to an exact +char-offset behead only if no acceptable newline is found nearby. + +### 3.3 The separator character — confirms `\n`, not `\r\n` + +``` +0079c280 data_79c280: 0d 00 0a 00 00 00 00 00 // L"\r\n" — used elsewhere, NOT here +0079c288 data_79c288: 0a 00 00 00 00 00 00 00 // L"\n" — the separator + the TruncateChatLog needle +``` + +`data_79c288` is passed both as the inter-line separator string appended in +`RecvNotice_DisplayFinalStringInfo` (§6) and as the `FindChar` needle in +`TruncateChatLog` above — confirming truncation genuinely searches for line +breaks, i.e. it is line-boundary-aware even though the budget itself is +counted in characters. + +### 3.4 Auto-scroll / "stick to bottom" — `IsAtVerticalEnd` + `ScrollToPosition` + +`UIElement_Text::IsAtVerticalEnd @0x00469350`: + +``` +00469350 uint8_t __fastcall UIElement_Text::IsAtVerticalEnd(class UIElement_Text* this) +00469350 { + lineCount = this->m_glyphList.m_glyphList._num_elements; +00469359 if (lineCount == 0) + return 1; // empty log counts as "at end" +00469360 lastLineIndex = lineCount - 1; +00469369 return UIElement_Text::IsPositionInView(this, &lastLineIndex); +00469350 } +``` + +**This is not a scroll-offset comparison — it is "is the last line +currently visible inside the viewport right now."** `IsPositionInView` is +the same hit-test the widget uses for click-to-position, applied to the +transcript's own final line. + +`RecvNotice_DisplayFinalStringInfo` captures this **before** appending the +new line, then decides what to do with it **after** appending and +truncating: + +``` +004f46d3 ebx = UIElement_Text::IsAtVerticalEnd(this->m_chatLog); // BEFORE the new line lands + … append prefix, append body, truncate if over budget (§6, §3.1) … +004f4723 if (ebx != 0) +004f4723 { +004f4732 UIElement_Text::ScrollToPosition(m_chatLog, currentLineCount); // re-stick to the new bottom +004f4739 return; +004f4723 } +004f4723 else +004f473c this->m_chatNewNonVisibleTextIndicator->vtable->SetState(1); // flag "unseen text" instead +``` + +**So: if the user was already looking at the bottom of the log, retail +scrolls the new line into view (sticky-bottom). If the user had scrolled up +into history, retail does NOT move their scroll position at all — it +instead lights the "new unseen text" indicator.** There is no separate +manual "scroll lock" toggle; this automatic per-line check IS retail's +scroll-lock mechanism. `m_chatNewNonVisibleTextIndicator` is a real +`UIElement*` field (`acclient.h:54906`), bound in `PostInit` from element +id `0x1000048C` — the 16×16 button the window-shell doc's layout dump +already placed at (21,62) in the main window and (5,169) in the floaties, +labeled there "new-unseen-text indicator (Button)" from the authored rect +alone; this document supplies the code that drives it. + +### 3.5 Clearing the unseen-text flag + +`ChatInterface::ListenToElementMessage @0x004F51C0`, click-message case, +`idElement == 0x1000048c`: + +``` +004f51f1 if (idElement == 0x1000048c) +004f51f1 { + if (m_chatEntry_or_chatLog != 0) // see field-shift caveat below +004f5208 UIElement_Text::ScrollToPosition(transcript, transcript->lineCount); +004f520d indicator->vtable->SetState(0xd); + } +``` + +**Field-attribution caveat:** this function's local variable is BN-named +`m_chatEntry` at the point it calls `ScrollToPosition`, but the object it +scrolls is described by `_num_elements` of its own `m_glyphList` — the +transcript's own line count, not the chat-entry input field's. Combined +with the ctor/struct order (§1.1) and the same-class shift already +documented in the window-shell doc for `PostInit`, the operation this +really performs is: **clicking the unseen-text indicator scrolls the +transcript to its own bottom and resets the indicator's own visual state** +(`SetState(0xd)`, a different state than the "flagged" `SetState(1)` set +when new text arrives while scrolled up) — i.e. clicking it is the user's +manual "catch up" action, and it un-flags itself. **Not independently +re-verified via cdb; treat the exact numeric visual STATE values (1 vs +0xd) as confirmed, but the specific field bound to "which object gets +scrolled to bottom" as inferred from the semantics of `IsAtVerticalEnd` +elsewhere, not a literal read of this function's own variable names.** + +--- + +## 4. Window chrome & interaction + +**Fully covered by the window-shell doc §1 and §2–§4 — not re-derived +here.** Summary pointers: + +- **Move/resize:** eight authored `UIElement_Resizebar` (type 9) grips per + window with per-grip bool properties `0x2A`/`0x2B`/`0x2C`/`0x2D` + (bottom/left/right/top); the main window's plain top edge strip is a + `UIElement_Dragbar` (type 2, move-only) rather than a ninth resize grip — + window-shell doc §2.1/§2.3, `UIElement_Resizebar::StartMouseResizing + @0x0046B7E0`. +- **Docking/anchoring:** none found — windows are free-floating, clamped + to stay on-screen only at restore time (`gmFloatyMainChatUI::MoveTo + @0x004D2D10:004d2d53-004d2dbb`). +- **Opacity:** two GLOBAL floats (`Option_DefaultOpacity_Property + 0x10000080` unfocused, `Option_ActiveOpacity_Property 0x10000081` + focused), applied to the WHOLE composited window surface including text + via one `SetOpacity` call — window-shell doc §3, `ChatInterface::SetOpacity + @0x004F3120`. Per-class constructed starting values differ (main window + 1.0/1.0 always-opaque, floaties 0.5/1.0) until a saved option overrides + them. Retail eases toward the target at 5%-of-delta per tick + (`ChatInterface::ListenToGlobalMessage @0x004F3840`); acdream currently + snaps (AP-190, window-shell doc §3.1). +- **Show/hide:** authored elements toggled via `SetVisible`, driven by + either a keybind (`Alt+1..4` for the floaties) or a generic + registered-action click dispatch — window-shell doc §1.3/§1.4. +- **Persistence:** two independent paths — the per-window `GameplayOptions` + blob (position/size/visible/title, gated on `m_eWindowID != 0`, i.e. the + main window's geometry is NEVER saved this way) and a separate local + screen-layout text file that IS the only path persisting the main + window's geometry — window-shell doc §4. + +**One piece of chrome not covered by the window-shell doc — the talk-focus +menu (main window only):** + +`gmMainChatUI::InitTalkFocusMenu @0x004CDC50` builds a dropdown menu (from +the button/group pair at elements `0x10000014`/`0x10000015`, window-shell +doc §2.1) with a squelch-toggle entry plus 13 target items, each carrying +an `Enum` attribute `0x1000000B` set to a distinct small integer (`1` +through `0xD`) that records which "talk focus" (broadcast target category) +that menu row represents: + +``` +004cdcd3 this->m_pSquelchToggleButton = UIElement_Menu::AddTextItem(eax_1, &var_90); +… (13x) … +004cdcfb UIElement::SetAttribute_Enum(eax_3, 0x1000000b, 5); +004cdd07 SmartArray::push_back(&this->m_aTalkFocusButtons, &var_94); +``` + +`gmMainChatUI::EnableSelection @0x004CE0A0` toggles individual rows' +enabled/greyed state (`SetState(0xd)` when Olthoi-locked); a companion +`RecvNotice_SelectionChanged @0x004CE050` re-syncs the menu's currently +highlighted target whenever the player's WORLD selection changes (via +`ACCWeenieObject::selectedID` and `PublicWeenieDesc::IsTalkable`) — this is +a **world-object selection** feed (F1-click on an NPC), not a transcript +text-tag click, and is out of this document's lane beyond noting that the +main window's talk-focus button exists and is driven from it. Only the +main window has this menu; floaty windows (window-shell doc §2.2) have +neither a talk-focus menu nor a max/min button, only a title bar and close +button. + +--- + +## 5. Tabs / multiple windows + +**Fully covered by the window-shell doc §1.1–§1.4, §2, §4.1 — not +re-derived here.** Summary: + +- There is no "tab" widget. The five windows (§1.1) are five separate, + independently positioned/sized/opaque floating panels, not tabs of one + container. +- **Creation:** none — all five exist from gameplay-UI construction; users + cannot create additional windows. **Naming:** each floaty window has an + editable title (`gmFloatyChatUI::SetWindowTitle @0x004CEAA0`, persisted + option `0x1000008D`) but the SET of windows is fixed at five; there is no + "new chat tab" affordance analogous to modern MMO UIs. **Closing:** + floaty windows close via their own title-bar close button + (`gmFloatyChatUI::ListenToElementMessage @0x004CE330`, element + `0x1000052A`) or the `Alt+N` toggle; the main window cannot be closed at + all (no close button is authored on it — window-shell doc §2.1's element + table has none). **Switching:** there is no focus-cycling shortcut found; + each window is an independent, simultaneously-visible panel, and + "switching" only means moving keyboard focus into a different window's + entry field by clicking it (which is what drives the opacity fade, + §4/window-shell doc §3). +- **Per-window state:** `m_eWindowID`, `m_llTextTypeFilter` (§2), + `DefaultOpacity`/`ActiveOpacity` (global, not per-window — window-shell + doc §3 correction), position/size/visible/title (§4), and the transcript + itself (`m_chatLog`, independently truncated per §3 — each window keeps + its own scrollback, so a floaty showing only Tells has its own 10k/7.5k + character budget separate from the main window's). +- The main window's four indicator buttons (`0x10000522`-`0x10000525`) + mirror the four floaties' visibility as one-directional state indicators, + not a tab strip — window-shell doc §1.4. + +--- + +## 6. Timestamps, prefixes, and line composition order + +### 6.1 The two-part composition model — confirmed structurally + +`ClientSystem::AddTextToScroll @0x00563C50` is where a body string +(`arg2`), a `LogTextType` (`arg3`), a plugin-hook flag (`arg4`) and a +windowId (`arg5`) become the two `StringInfo` arguments +`RecvNotice_DisplayFinalStringInfo` receives. Its structurally relevant +branch (client-local `0x1A` short-circuits both the timestamp AND the local +log file): + +``` +00563de6 if (arg3 == 0x1a) +00563de6 { + // build body-only StringInfo, EMPTY prefix StringInfo +00563f2b ECM_UI::SendNotice_DisplayFinalStringInfo(arg3, &bodyOnly, &emptyPrefix, windowId); +00563de6 } +00563de6 else +00563de6 { +00563dfb if (PlayerModule::DisplayTimeStamps(&playerModule) != 0) +00563dfb { +00563e24 wcsftime(&buf, 0x400, u"%#H:%M:%S ", localtime(&now)); // "H:MM:SS " — no date, trailing space +00563e39 PStringBase::set(&prefixBuffer, &buf); +00563dfb } + … if (s_pLogFile) fprintf(s_pLogFile, "%ls%ls\n", prefixBuffer, bodyBuffer); // §7.4 + } +``` + +**There is exactly ONE structural prefix element: the timestamp, and it is +entirely optional (gated on `PlayerModule::DisplayTimeStamps()`, a +character option toggle backed by `PlayerModule::options2_` bit 6 — +`PlayerModule::DisplayTimeStamps @0x005D39B0`: `return (options2_ >> 6) & +1`).** There is **no separate structural "channel name" prefix element** +(`[Fellowship]`, `[]`, etc.) anywhere in this function or in +`RecvNotice_DisplayFinalStringInfo`. Channel-name brackets that DO appear +in retail's transcript (documented already, by content not structure, in +the color-table doc §3.3's channel-bit table) are baked directly into the +`arg2` body string by the SENDING handler (e.g. +`Handle_Communication__ChannelBroadcast`) before it ever reaches +`AddTextToScroll` — from this function's point of view there are only ever +two composed parts: prefix (timestamp-or-empty) and body. + +### 6.2 The append order — separator, then prefix, then body + +Back in `RecvNotice_DisplayFinalStringInfo @0x004F4640` (full body, per the +excerpts in §1.2/§3.1/§3.4 stitched together in call order): + +``` +004f467c if (this->m_chatLog->m_glyphList.m_glyphList._num_elements > 0) +004f4687 { +004f469a UIElement_Text::AppendTextWithFont(this->m_chatLog, L"\n", 0, arg2 /*type*/); +004f467c } // 1. separator (skipped on the very first line) +004f46d3 ebx = UIElement_Text::IsAtVerticalEnd(this->m_chatLog); // captured BEFORE any of the below +004f46dc if (StringInfo::IsValid(arg4, 1) != 0) +004f46e9 UIElement_Text::AppendStringInfoWithFont(this->m_chatLog, arg4 /*prefix*/, 0, 0xc); + // 2. timestamp prefix — ALWAYS color idx 0x0C (grey), only if valid/non-empty +004f46fc UIElement_Text::AppendStringInfoWithFont(this->m_chatLog, arg3 /*body*/, 0, arg2 /*type*/); + // 3. body — colored by the wire LogTextType +``` + +**Fixed structural order: `[\n if not first line] → [timestamp, if +enabled] → [body]`.** The leading separator is a property of the LOG +(inserted once per new entry, before the entry, so the transcript never +starts with a blank line), not a property of the entry itself — a port that +appends `body + "\n"` per-line instead of `"\n" + body` will still LOOK +identical on screen but will behave differently under `TruncateChatLog`'s +newline-boundary search (§3.2) and under `IsAtVerticalEnd` line-counting +(§3.4) if the two approaches disagree at the very first/last line. The +color assignment itself is the color-table doc's territory (not re-derived +here) — the load-bearing NEW fact this document adds is the *order* and +that the timestamp is unconditionally color index `0x0C` regardless of the +body's own type, which the color-table doc §3.2 already states from the +same address; this document supplies the surrounding append sequence and +confirms the timestamp's StringInfo is `arg4`, always appended strictly +BEFORE the body `arg3`, never interleaved or after. + +### 6.3 Timestamp format, verbatim + +`u"%#H:%M:%S "` fed to `wcsftime` — hour without a leading zero, minute, +second, **no date**, one trailing space baked into the format string +(explaining why no separate space-insertion code exists between prefix and +body — the prefix string itself carries its own trailing separator). + +--- + +## 7. Other user-visible window behaviors + +### 7.1 Local session log file — a port would miss this + +`ClientSystem::s_pLogFile` — a plain-text file retail writes chat lines to +during the session, independent of the on-screen transcript's 10k/7.5k +character budget (§3.1) or any window's filter (§2). Written from the same +`AddTextToScroll` branch that builds the on-screen timestamp (§6.1): + +``` +00563e5b fprintf(ClientSystem::s_pLogFile, "%ls%ls\n", prefixBuffer, bodyBuffer); +``` + +Client-local type `0x1A` text (§6.1's short-circuit branch) explicitly +bypasses this — client-local errors/refusals never reach the log file, +only the on-screen transcript. **UNKNOWN — needs further grep:** the log +file's path/naming convention and whether it rotates per-session or +per-character; not chased further as it's a filesystem-artifact question +more than a window-UI one, but flagged because "retail also writes a +plain-text chat log to disk" is exactly the kind of behavior a UI-only port +would miss entirely. + +### 7.2 Unread/unseen marker — confirmed, see §3.4/§3.5 + +The `0x1000048C` "new unseen text" indicator button IS retail's unread +marker. It is per-window (each `ChatInterface` owns its own +`m_chatNewNonVisibleTextIndicator`), lights when a broadcast/addressed line +arrives while the user has scrolled away from the bottom, and clears when +the user clicks it (which also snaps the transcript back to its bottom). +There is no separate "flash the window" or "flash the taskbar/app icon" — +`FlashWindow`/`FlashWindowEx` do not appear anywhere in the pseudo-C dump +(checked via a whole-file grep; zero hits). + +### 7.3 Sound cues on incoming chat — UNKNOWN, likely none dedicated + +A targeted grep for `PlaySound`/`SoundManager::Play*` near the +`Handle_Communication__HearDirectSpeech @0x005715A0` (incoming tell) handler +body found no sound-manager call inside it, and no `Sound_*`-named constant +resembling "tell received" or "chat" turned up in the identifiers swept. +The one chat-adjacent audio-related symbol found is a **global** preference +— `Sound_PlaySoundOnlyWhenActive` / `ID_Sound_NoFocusNoSound` +(`UIPreferences::AttachPreference @0x004037E4`, +`SoundManager::PlaySoundInternal @0x0054FEC0` checks +`SoundManager::s_bPlaySoundOnlyWhenActive` against `Device::m_bIsActiveApp`) +— which mutes ALL UI sounds (not specifically chat) when the game window +isn't the active app. **UNKNOWN — needs a deeper sweep or a live cdb +capture on an incoming tell**: this document did not find a chat-specific +sound cue, but a negative grep result over a 66 MB pseudo-C dump is weak +evidence of absence given how many code paths route through indirect +vtable calls the text search can't follow. Flagging rather than asserting +"retail has no tell sound." + +### 7.4 Copy/paste and text selection — a base `UIElement_Text` capability + +`UIElement_Text::GetSelection @0x00466F20` and `UIElement_Text::SelectAll +@0x004678D0` exist as capabilities of the general text-widget class that +BOTH the chat entry field (`m_chatEntry`) and the read-only transcript +(`m_chatLog`) are instances of (`acclient.h:54904-54905`, both typed +`UIElement_Text*`). `SelectAll`'s call sites found are mostly OTHER +text-entry fields (a character-name box, a stack-size entry box) triggered +by a "select-all-on-first-click" attribute (`UIElement::GetAttribute_Bool(this, +0xd1, ...)` inside `UIElement_Text::MouseDown @0x00469370`), not anything +chat-specific. **UNKNOWN — not independently confirmed for the read-only +transcript specifically:** whether the transcript panel exposes the SAME +click-drag-select-then-copy affordance as the entry field, or whether it is +flagged read-only in a way that suppresses selection; the class-level +capability clearly exists on the type, but no chat-transcript-specific +selection code path was located distinct from the generic `UIElement_Text` +mouse-down handler already cited. Worth a live-client check (select text in +the retail transcript, see if a selection highlight appears) rather than +further static digging. + +### 7.5 What's genuinely absent + +- No `FlashWindow` anywhere in the binary (§7.2). +- No docking/snapping between chat windows or to screen edges — the + window-shell doc's resize/move research found only free-floating + clamped-on-restore positioning (§4). +- No tab strip / tabbed-window container (§5) — five independent panels, + not a tab model. +- No manual "scroll lock" toggle — the auto-scroll behavior in §3.4 IS the + scroll-lock mechanism, driven automatically by `IsAtVerticalEnd`, with no + user-facing on/off switch found. + +--- + +## Behaviours acdream is most likely missing + +Ordered by how load-bearing each gap looks against `RuntimeCommunicationState` +(`docs/research/2026-07-26-slice-j4-1-communication-state.md`) and +`ChatWindowController` as of this session: + +1. **Scrollback truncation entirely.** No 10,000-char trigger / ~7,500-char + target / newline-boundary-preferring trim (§3.1–§3.3) appears to exist in + acdream today — grep `TruncateChatLog`-equivalent behavior in + `ChatLog`/`ChatWindowController` before assuming an unbounded transcript + is fine; it will diverge from retail under long play sessions (memory + growth) and, more subtly, under the exact wrap point if a port ever needs + pixel/line parity with a retail screenshot at high message volume. +2. **The auto-scroll / stick-to-bottom vs. flag-unseen-instead split + (§3.4–§3.5).** This is a genuine behavioral fork, not a cosmetic one: a + naive port that ALWAYS scrolls to bottom on new text will yank the user's + scroll position out from under them mid-read whenever a broadcast line + arrives — exactly the annoyance retail's `IsAtVerticalEnd` check exists to + prevent. Confirm `ChatWindowController` checks "was I at the bottom + before this line landed" before auto-scrolling, and confirm the + `0x1000048C` unseen-indicator element (window-shell doc's layout dump + already has its rect for both window layouts) is wired to light up + + clear via click exactly as §3.4/§3.5 describe. +3. **The window-ID routing predicate as ONE explicit rule (§1.2).** The + color-table doc already flags the routing behavior; this document adds + the exact decompiled shape. Verify `RuntimeCommunicationState`'s chat + windows model (per the CH6c plan in the window-shell doc §6.1) implements + precisely `windowId == m_eWindowID || (windowId == 0 && TypeIsActive)` — + not, e.g., "every window with the type enabled shows every line + regardless of address," which would make addressed command-output lines + leak into windows they were never meant for. +4. **Structural composition order (§6.2)** — separator-before-entry (not + after), timestamp-before-body, timestamp always present-or-absent as a + single unit gated on one option bit. A port that concatenates + `timestamp + " " + body` as one string loses retail's separately-colored, + separately-truncatable prefix run and the option-driven all-or-nothing + presence. +5. **The local session chat-log file (§7.1).** Small, but "retail writes a + plain-text transcript to disk every session" is the kind of feature users + notice is missing only when they go looking for it after the fact. +6. **Per-window independent scrollback.** Once §1 is implemented, confirm + each of the five windows truncates its OWN transcript independently + (§5) rather than sharing one global buffer — a floaty window filtered + down to just Tells should never truncate early just because the main + window's transcript is huge. +7. **Sound cues and transcript text-selection are open questions, not + confirmed gaps** (§7.3, §7.4) — do not build negative-result "retail has + none of this" code around them; re-check live if/when they become + relevant. From ba6c82af9399cbefcb6747133c8eab7ab8d28d76 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 07:13:55 +0200 Subject: [PATCH 08/43] docs: Campaign CT rescoped to complete chat parity, system + GUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user set the goal: complete retail parity for the chat system AND the chat GUI, not just the green clickable name that started the review. Plan restated around that bar, with a definition of done — every retail behaviour either implemented or carrying a divergence-register row, every user-visible surface covered by a test, and the digest/ISSUES describing reality. Slices regrouped into A (the tagged-text capability, a strict chain where nothing is visible until A4), B (system behaviours), C (GUI), D (hygiene), plus the research still owed before specific slices and what is deliberately out of scope. Four items joined the plan that the original six lanes did not own, because they fell between lanes: - the /r, /t, /tell text-replacement macro (the commands work; retail's VISIBLE expansion to "@tell {LastTeller}, " does not exist) - FilterLanguage, which is a decorative toggle: we store it, ship the bit and show it in Options, and never actually filter anything - the plain-text session chat log retail writes and we do not - the option-gated timestamp prefix Also corrects the CH3 command-registry research note. Its "acdream status" columns are from before slice CH4 and list 13 verbs as MISSING that have all since been added — cg, soc, o, co-vassals, fellows, group, party, vassal, ab, guild, ct, clfg, crp — and its DIVERGENT row for /g is likewise stale: acdream maps /g to Fellowship, matching retail, confirmed against the live client today. The retail side of that document is still the authority; only the columns describing us were wrong. They misled this session's investigation, which is exactly why the banner says to verify against ChatInputParser.cs. Nothing implemented. Co-Authored-By: Claude Opus 5 --- .../2026-08-21-chat-text-tag-campaign.md | 203 ++++++++++-------- ...2026-08-09-chat-retail-command-registry.md | 15 ++ 2 files changed, 125 insertions(+), 93 deletions(-) diff --git a/docs/plans/2026-08-21-chat-text-tag-campaign.md b/docs/plans/2026-08-21-chat-text-tag-campaign.md index f80459dd..7b109d0f 100644 --- a/docs/plans/2026-08-21-chat-text-tag-campaign.md +++ b/docs/plans/2026-08-21-chat-text-tag-campaign.md @@ -1,126 +1,143 @@ -# Campaign CT — chat text tags and chat-window parity +# Campaign CT — complete chat parity (system + GUI) **Status:** PROPOSED (2026-08-21). Not started. -Retail renders a speaker's name inside a chat line in green, and clicking it -opens a tell to that person. acdream renders flat, uniformly coloured, inert -lines. Six parallel research lanes established why, and the answer is not a -chat bug — it is a **missing capability in the text stack**. +**Goal, set by the user 2026-08-21: complete retail parity for the chat +system AND the chat GUI.** Not "fix the green name" — that was the symptom +that started the review. The bar is that a retail player sitting down in front +of acdream's chat window finds nothing missing and nothing behaving +differently. + +Campaign CH (2026-08-09, closed user-accepted) landed colours, side channels, +the 152-verb command registry, the window shell and verbatim `/help`. CT is +the pass that closes what CH did not reach. Research notes (all 2026-08-21): `chat-texttag-model.md`, `chat-tagged-name-composition.md`, `chat-tag-click-dispatch.md`, `retail-chat-window-ui.md`, `acdream-text-stack-audit.md`, `acdream-chat-ui-audit.md`. -## The mechanism, proven +## Definition of done -1. The CLIENT composes the markup. `Handle_Communication__HearSpeech - @0x005712A0` / `HearDirectSpeech @0x005715A0` sprintf a literal tag into the - plain chat line, of the shape - `{name}<\Tell> says, "{text}"` — the closing - marker is a literal backslash. Only senders whose GUID is in AC1's player - range `0x50000001..0x6FFFFFFF` are tagged at all. +1. Every retail chat behaviour is either implemented, or has a divergence- + register row saying why not. +2. Every user-visible chat surface has a test that would catch its regression. +3. The chat digest and `docs/ISSUES.md` describe reality (both are stale today). -2. `UIElement_Text::InqGlyphs @0x00468EA0` recognises the brackets while - appending and calls `TextTagFactory::MakeTag @0x00478480`. Any bracketed - text that fails to parse closes the open tag — `MakeTag` requires a `:` to - succeed, which is exactly what makes the bare closer a closer. +## What the review established -3. Tags attach **per glyph**. There is no run or span object anywhere: a - "tagged run" is emergent, re-derived by walking neighbouring glyphs whose - `m_tag` pointers are equal. +### The green clickable name is a TEXT-STACK gap, not a chat gap -4. Colour: a glyph takes the TAG colour (property `0x1D`) only when a tag is - open AND its `m_type == 0x10000001`; otherwise the ordinary line colour - (`0x1B`). Both are DAT-authored arrays on the element. +The client sprintfs literal markup into the line — +`{name}<\Tell> says, "{text}"`, closing marker a +literal backslash — and `UIElement_Text::InqGlyphs @0x00468EA0` parses the +brackets while appending, calling `TextTagFactory::MakeTag @0x00478480`. Tags +attach **per glyph**; a "run" is emergent (adjacent glyphs with equal tag +pointers). A glyph takes the tag colour (property `0x1D`) only when a tag is +open AND its `m_type == 0x10000001`, else the line colour (`0x1B`). Only +senders with a GUID in `0x50000001..0x6FFFFFFF` are tagged. -5. **Measured** out of the installed dats (`LayoutDump --colors`), chat - `0x2100006F`, transcript `0x10000011`: +Colour **measured** from the installed dats (`LayoutDump --colors`), chat +`0x2100006F` / transcript `0x10000011`: `P0x1B` = RGB(204,204,204), +`P0x1D` = **RGB(0,178,0)**. The tag colour is per-ELEMENT and authored, while +the line colour on that same element comes from the runtime chat table — +filing "tag green" into the LogTextType table would put it in the wrong place. - P0x1B (line) [0x00] R=204 G=204 B=204 - P0x1D (tag) [0x00] R= 0 G=178 B= 0 <- the green +Click: `UIElement_Text::MouseUp @0x004694F0` → `DeterminePositionFromXY +@0x004688F0` → `GlyphList::InqGlyph @0x00473430` → virtual `HandleClick` at +tag-vtable `+0x14` → `gmMainChatUI::RecvNotice_TextTag_IIDStringClick +@0x004CCE10` → `ChatInterface::StartTell @0x004F41F0`, which writes +`"@tell {Name}, "`, takes focus, and shows the entry bar. Clicking a name +always opens a TELL — fellowship, allegiance, patron/vassal and named-channel +lines all embed the same markup. No hover effect. -6. Click: `UIElement_Text::MouseUp @0x004694F0` → `DeterminePositionFromXY - @0x004688F0` (xy → glyph index) → `GlyphList::InqGlyph @0x00473430` → a - virtual `HandleClick` at tag-vtable `+0x14` → `SendNotice_TextTag_*Click` → - `gmMainChatUI::RecvNotice_TextTag_IIDStringClick @0x004CCE10` → - `ChatInterface::StartTell @0x004F41F0`, which writes `"@tell {Name}, "` into - the entry, takes keyboard focus, and shows the entry bar. +### Our side is closer than feared -Two facts that shape the port: +`UiText` **already** draws multi-coloured runs (`TextRun`/`RunsProvider`, used +by the character stat panel); it is gated to `OneLine == true`. The draw path +needs no renderer work — arbitrary pen X, substring measurement — and +`UiText.HitChar` already resolves a click to (line, column). The blocker is +that `ChatVM.RecentLinesDetailed()` drops `Sender`/`SenderGuid` one step before +the renderer, though `ChatEntry` carries them the whole way. -- **The tag colour is per-ELEMENT and authored**, while the line colour on the - same element comes from the runtime chat table. Filing "tag green" into the - LogTextType colour table would put it in the wrong place. -- **Clicking a name always opens a TELL**, everywhere. Fellowship, allegiance, - patron/vassal and named-channel lines all embed the same markup. Dispatch is - generic over four tag shapes, but only `IIDString` has a listener in this - build. +### The command registry is already at parity -Retail applies no hover effect to a tag, and the colour is a static per-glyph -bake at append time — not a render-time lookup. - -## What we already have - -The audit found more than expected. `UiText` **already** has a -`TextRun`/`RunsProvider` path that draws several differently-coloured runs on -one line (used today by the character stat panel) — it is simply gated to -`OneLine == true`, and the chat transcript is multi-line. The draw path needs -no renderer work at all: it already accepts an arbitrary pen X and can measure -substrings. `UiText.HitChar` already resolves a click to (line, column). - -So the gap is narrower than "build a text tag system": - -- the multi-line path cannot carry runs, and -- sender identity is **destroyed before it reaches the renderer**: `ChatEntry` - keeps `Sender`/`SenderGuid` all the way through `ChatLog`, and - `ChatVM.RecentLinesDetailed()` builds a `FormattedLine` that drops both. +All 13 verbs the CH3 research note lists as MISSING were closed by CH4 and +verified present 2026-08-21 (`cg`, `soc`, `o`, `co-vassals`, `fellows`, +`group`, `party`, `vassal`, `ab`, `guild`, `ct`, `clfg`, `crp`). `/g` correctly +resolves to Fellowship, confirmed against the live retail client. That note's +"acdream status" columns are stale and now carry a correction banner. ## Slices -**CT1 — runs on the multi-line text path.** Extend the existing `TextRun` -model to multi-line elements; per-line run lists; additive, so `Line` keeps -working and the ~50 files using it are untouched. No behaviour change. +### Group A — the tagged-text capability (strict chain, A1→A5) -**CT2 — markup parse.** Parse the tag markup into runs carrying a tag payload, -including retail's rule that an unparseable bracket closes the open tag. Pure -and unit-testable, no UI. +Nothing is user-visible until A4. -**CT3 — stop flattening, and emit the markup.** Carry sender name + guid -through `ChatVM` into spans, and compose retail's markup in the speech handlers -behind the player-GUID-range gate. This is the slice that makes the name a -distinct run at all. +- **CT-A1** Multi-line text elements carry coloured runs. Additive; the ~50 + files using `Line` are untouched. No behaviour change. +- **CT-A2** Parse the tag markup into runs with a tag payload, including + retail's rule that an unparseable bracket closes the open tag. Pure, unit- + testable, no UI. +- **CT-A3** Stop flattening: carry sender name + guid through `ChatVM` into + spans, and compose retail's markup in the speech handlers behind the + player-GUID-range gate. +- **CT-A4** Apply the authored `0x1D` tag colour when a tag is open and its + type matches. **Names turn green.** +- **CT-A5** Sub-line hit-testing and `StartTell`. **Names become clickable.** -**CT4 — tag colour.** Read the authored `0x1D` array per element and apply it -when a tag is open and its type matches. Uses CT1's runs. +### Group B — chat SYSTEM behaviours -**CT5 — click to tell.** Sub-line hit-testing (`HitChar` → run → tag) and -`StartTell` behaviour: write `"@tell {Name}, "`, focus the entry, show the -entry bar. Dispatch keyed generically by tag type, with only `IIDString` wired. +- **CT-B1** Bound the transcript: 10,000 chars, trim to ~7,500 preferring a + newline boundary (`TruncateChatLog @0x004F4290`). Today it grows for the life + of the session — a slow leak, not only a fidelity gap. +- **CT-B2** Text-replacement macros: typing `/r `, `/t `, `/tell ` rewrites the + input to `@tell {LastTeller}, ` on the space keypress + (`HandleTextReplacements @0x004F50D0`). The commands already work; the + visible expansion does not exist. +- **CT-B3** `FilterLanguage`: retail runs chat text through a taboo table and + substitutes (`PlayerModule::FilterLanguage` + `TabooTableAdaptor:: + CheckCensorsW` inside `AddTextToScroll`). We store the option, ship the bit, + show it in Options — and never filter. A decorative toggle. +- **CT-B4** The plain-text session chat log (`ClientSystem::s_pLogFile`). We + write none. Path and rotation are UNKNOWN — needs research or a live check. -**CT6 — chat window behaviours.** Bound the transcript (10,000 chars, trim to -~7,500 preferring a newline boundary); split auto-scroll from the unread -indicator (`0x1000048C` — retail samples "was at bottom" BEFORE the line -lands); the option-gated timestamp prefix. Unbounded scrollback is also a slow -leak for the life of a session, not only a fidelity gap. +### Group C — chat GUI -**CT7 — tail.** The Escape-in-chat-input no-op the audit found (no `Escape` -case in `UiField`, and a focused field also suppresses the input dispatcher's -fallback, so nothing happens at all); delete the dead ImGui-era `ChatPanel`; -reconcile the stale digest/ISSUES rows (#358, #362, #363, #367, #372, #379, -#380, #382 are DONE in code but still listed open). +- **CT-C1** Auto-scroll vs unread: retail samples "was at bottom" BEFORE the + line lands; if you had scrolled up it leaves you there and lights the unread + indicator (`0x1000048C`), which scrolls to bottom and clears on click. +- **CT-C2** Escape in the chat input is a complete no-op — `UiField` has no + `Escape` case, and a focused field also suppresses the input dispatcher's + fallback, so there is no clear, no defocus and no hotkey passthrough. +- **CT-C3** Option-gated timestamp prefix (`%#H:%M:%S `, colour index `0x0C`, + grey), gated on `PlayerModule::DisplayTimeStamps()`. +- **CT-C4** Input-bar editing parity: clipboard and selection paths + (Ctrl+C/X/V, shift-selection) work but are untested; `ToggleMaximize` and the + floating-window Close button have zero coverage. + +### Group D — hygiene + +- **CT-D1** Delete the dead ImGui-era `ChatPanel` (never constructed since + Campaign V deleted `AcDream.UI.ImGui`), and its three test files, which + currently make the real input surface look better covered than it is. +- **CT-D2** Reconcile the chat digest and `docs/ISSUES.md`: #358, #362, #363, + #367, #372, #379, #380, #382 are DONE in code but still listed open. #359, + #360, #361, #366 remain genuinely open. + +## Research still owed before the affected slices + +- The tag-type roster behind `m_type == 0x10000001` — only "Tell" is + confirmed; the full set lives in the DAT `EnumMapper` category `0x18`. + Blocks nothing in Group A, but decides whether other tag shapes exist. +- Whether retail's transcript supports text selection distinctly from the + entry field (blocks CT-C4's scope). +- The chat log file's path and rotation (blocks CT-B4). +- Whether a chat-specific sound cue exists — a grep came back empty, which is + weak evidence, not proof of absence. ## Deliberately NOT in scope Item links and the other three tag shapes (`DID`, `IID`, `IIDEnum`). They have no listener in the retail build we target, so porting them would be inventing -behaviour. CT5's dispatch is generic, so they cost nothing to add later. - -## Known-unknowns carried - -- The symbolic name behind tag type `0x10000001` (only "Tell" is confirmed); - the full roster lives in the DAT `EnumMapper` category `0x18`. -- Whether the retail transcript supports text selection distinctly from the - entry field. -- The chat log file's path and rotation (`ClientSystem::s_pLogFile`) — retail - writes a plain-text session log a port would miss entirely. +behaviour. CT-A5's dispatch is generic, so they cost nothing to add later. diff --git a/docs/research/2026-08-09-chat-retail-command-registry.md b/docs/research/2026-08-09-chat-retail-command-registry.md index 0a7e66b2..84d2450a 100644 --- a/docs/research/2026-08-09-chat-retail-command-registry.md +++ b/docs/research/2026-08-09-chat-retail-command-registry.md @@ -1,5 +1,20 @@ # Retail client slash-command registry — complete enumeration + acdream audit +> **CORRECTION 2026-08-21 — the "acdream status" columns below are STALE.** +> They record the state on 2026-08-09, BEFORE slice CH4 landed. Every verb this +> document calls MISSING has since been added and verified present in +> `ChatInputParser.ChannelVerbs`: `cg`, `soc`, `o`, `co-vassals`, `covassal`, +> `c`, `fellows`, `group`, `party`, `vassal`, `ab`, `guild`, `gu`, `ct`, +> `clfg`, `crp`. The DIVERGENT row for `g` was also corrected — acdream now +> maps `/g` to Fellowship, matching retail, confirmed against the live retail +> client on 2026-08-21. +> +> The RETAIL side of this document (the registered verbs, handler addresses and +> channel ids) remains accurate and is still the authority. Only the columns +> describing what acdream does are out of date; verify against +> `ChatInputParser.cs` before trusting them. + + Date: 2026-08-09 Status: RESEARCH ONLY. No production code was changed. From cf41b27c9a15ed2f125f68e59b2fbf94b5918227 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 07:18:55 +0200 Subject: [PATCH 09/43] =?UTF-8?q?feat(ui):=20CT-A1=20=E2=80=94=20multi-lin?= =?UTF-8?q?e=20text=20elements=20can=20carry=20coloured=20runs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Campaign CT slice A1. No behaviour change: this is the capability the rest of Group A needs. UiText already drew several differently-coloured runs on one line (TextRun/RunsProvider, used by the character stat panel) but the path was gated to OneLine == true, and the chat transcript is multi-line — so a chat line could only ever be one colour. Retail's is not: a tagged glyph run takes the element's TAG colour (property 0x1D) while the rest of the line takes the ordinary one (0x1B), per UIElement_Text::InqGlyphs @0x00468EA0. LineRunsProvider is a SIDECAR keyed by line index rather than a field on Line. Roughly fifty files construct Line, and widening its shape would put every one of them in the blast radius of a chat feature; a line with no runs draws exactly as before. The runs fold into the existing datLines list as extra entries at advancing pen-X, so the S1 outline-then-fill batching is untouched — a multi-colour line still submits its whole outline pass before any fill, and cannot notch the descender of the line above. Two things are deliberately load-bearing: - RunsMatchLine. Selection, hit-testing and the caret all index into the FLAT line text, so a run list that disagrees with it would draw one thing and select another. The draw path verifies the runs say exactly the same characters and falls back to the flat line if not, rather than trusting the caller. - LayoutRuns is pure. The pen-advance is the part that silently mis-renders if it drifts, so it is testable without a font atlas or a GPU — which also keeps its tests in the ordinary gate rather than the SystemFont lane. Solution builds clean; full hermetic gate green, 0 failures. Co-Authored-By: Claude Opus 5 --- src/AcDream.App/UI/UiText.cs | 98 ++++++++++++++++++++++- tests/AcDream.App.Tests/UI/UiTextTests.cs | 81 +++++++++++++++++++ 2 files changed, 178 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs index 1a9f64e3..8c228105 100644 --- a/src/AcDream.App/UI/UiText.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -68,6 +68,80 @@ public sealed class UiText : UiElement, IUiDatStateful /// public Func>? RunsProvider { get; set; } + /// + /// Optional per-line inline fragments for the MULTI-line path, by line + /// index. Return for a line that is a single colour. + /// + /// + /// + /// Campaign CT slice A1. Retail's chat transcript is not uniformly + /// coloured per line: a tagged glyph run (a speaker's name) takes the + /// element's TAG colour while the rest of the line takes the ordinary one + /// (UIElement_Text::InqGlyphs @0x00468EA0, properties 0x1D vs 0x1B). + /// This is the seam that lets a line carry more than one colour. + /// + /// + /// Deliberately a SIDECAR rather than a field on : + /// roughly fifty files construct Line, and changing its shape would + /// put every one of them in the blast radius of a chat feature. + /// + /// + /// Invariant: the concatenated run text MUST equal the line's own + /// . Selection, hit-testing and the caret all index + /// into that flat string, so a run list that disagrees with it would draw + /// one thing and select another. states the + /// check; the draw path falls back to the flat line rather than trusting a + /// mismatched list. + /// + /// + public Func?>? LineRunsProvider { get; set; } + + /// + /// Places a line's runs left-to-right from , + /// advancing the pen by each run's measured width. + /// + /// + /// Empty runs contribute no geometry but still advance the pen by their + /// (zero) width, so they cannot shift what follows them. Pure, so the + /// placement is testable without a font atlas or a GPU. + /// + internal static List<(string Text, float X, Vector4 Color)> LayoutRuns( + IReadOnlyList runs, + float startX, + Func measure) + { + var placed = new List<(string Text, float X, Vector4 Color)>(runs.Count); + float penX = startX; + for (int i = 0; i < runs.Count; i++) + { + TextRun run = runs[i]; + if (run.Text.Length != 0) + placed.Add((run.Text, penX, run.Color)); + penX += measure(run.Text); + } + return placed; + } + + /// + /// Whether a run list may be drawn in place of — + /// i.e. whether it says exactly the same characters. + /// + internal static bool RunsMatchLine(IReadOnlyList runs, string line) + { + int at = 0; + for (int i = 0; i < runs.Count; i++) + { + string text = runs[i].Text; + if (at + text.Length > line.Length + || string.CompareOrdinal(line, at, text, 0, text.Length) != 0) + { + return false; + } + at += text.Length; + } + return at == line.Length; + } + /// Font for the transcript; falls back to the context default. public BitmapFont? Font { get; set; } @@ -668,9 +742,31 @@ public sealed class UiText : UiElement, IUiDatStateful } } + IReadOnlyList? runs = LineRunsProvider?.Invoke(i); + if (runs is { Count: > 0 } && !RunsMatchLine(runs, text)) + runs = null; // never draw text that disagrees with what we select + if (datFont is not null) { - (datLines ??= new()).Add((text, lineX, y, lines[i].Color)); + datLines ??= new(); + if (runs is { Count: > 0 }) + { + // Several entries at advancing pen-X instead of one. The + // outline/fill batching below is untouched by this: it + // walks whatever entries are here, so a multi-colour line + // still gets its whole outline pass before any fill. + foreach (var placed in LayoutRuns(runs, lineX, datFont.MeasureWidth)) + datLines.Add((placed.Text, placed.X, y, placed.Color)); + } + else + { + datLines.Add((text, lineX, y, lines[i].Color)); + } + } + else if (runs is { Count: > 0 }) + { + foreach (var placed in LayoutRuns(runs, lineX, bitmapFont!.MeasureWidth)) + ctx.DrawString(placed.Text, placed.X, y, placed.Color, bitmapFont); } else { diff --git a/tests/AcDream.App.Tests/UI/UiTextTests.cs b/tests/AcDream.App.Tests/UI/UiTextTests.cs index 1ddf4feb..a5b4f5f2 100644 --- a/tests/AcDream.App.Tests/UI/UiTextTests.cs +++ b/tests/AcDream.App.Tests/UI/UiTextTests.cs @@ -8,6 +8,87 @@ namespace AcDream.App.Tests.UI; public class UiTextTests { + // ── Campaign CT slice A1: per-line coloured runs ──────────────────── + + private static UiText.TextRun Run(string text) + => new(text, Vector4.One); + + [Fact] + public void LayoutRuns_AdvancesThePenAcrossRuns() + { + // 10px per character, so the offsets are checkable by hand. + var placed = UiText.LayoutRuns( + [ + new UiText.TextRun("Dww", new Vector4(0f, 0.698f, 0f, 1f)), + new UiText.TextRun(" tells you", Vector4.One), + ], + startX: 4f, + measure: text => text.Length * 10f); + + Assert.Equal(2, placed.Count); + Assert.Equal(4f, placed[0].X); // first run starts at the line origin + Assert.Equal(34f, placed[1].X); // 4 + 3 chars * 10 + Assert.Equal(new Vector4(0f, 0.698f, 0f, 1f), placed[0].Color); + Assert.Equal(Vector4.One, placed[1].Color); + } + + [Fact] + public void LayoutRuns_EmptyRunsDrawNothingAndShiftNothing() + { + var placed = UiText.LayoutRuns( + [ + new UiText.TextRun(string.Empty, Vector4.One), + new UiText.TextRun("ab", Vector4.One), + new UiText.TextRun(string.Empty, Vector4.One), + new UiText.TextRun("cd", Vector4.One), + ], + startX: 0f, + measure: text => text.Length * 10f); + + // The two empties contribute no geometry, and because they measure zero + // they cannot displace what follows. + Assert.Equal(2, placed.Count); + Assert.Equal(0f, placed[0].X); + Assert.Equal(20f, placed[1].X); + } + + [Fact] + public void RunsMatchLine_AcceptsAnExactSplitOfTheLine() + { + Assert.True(UiText.RunsMatchLine( + [Run("Dww"), Run(" tells you, \"hi\"")], + "Dww tells you, \"hi\"")); + + // Order matters: the same pieces rearranged are a different line. + Assert.False(UiText.RunsMatchLine( + [Run(" tells you, \"hi\""), Run("Dww")], + "Dww tells you, \"hi\"")); + } + + [Fact] + public void RunsMatchLine_RejectsAnythingThatWouldDesyncSelection() + { + // Selection, hit-testing and the caret all index into the FLAT line, so + // a run list that says something else would draw one thing and select + // another. Every one of these must be refused. + const string line = "Dww tells you"; + + Assert.False(UiText.RunsMatchLine([Run("Dww")], line)); // short + Assert.False(UiText.RunsMatchLine([Run(line), Run("!")], line)); // long + Assert.False(UiText.RunsMatchLine([Run("Dwx"), Run(" tells you")], line)); // altered + Assert.False(UiText.RunsMatchLine([], line)); // empty vs text + } + + [Fact] + public void RunsMatchLine_HandlesTheDegenerateCases() + { + Assert.True(UiText.RunsMatchLine([], string.Empty)); + Assert.True(UiText.RunsMatchLine([Run(string.Empty)], string.Empty)); + // An empty run between real ones is harmless — it contributes nothing + // to the text and advances the pen by nothing. + Assert.True(UiText.RunsMatchLine([Run("ab"), Run(string.Empty), Run("cd")], "abcd")); + } + [Fact] public void WrapWords_UsesMeasuredWidthAndPreservesParagraphs() { From 2621fbf4a722ffae248efc7ab24859552bb1352d Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 07:23:07 +0200 Subject: [PATCH 10/43] =?UTF-8?q?feat(chat):=20CT-A2=20=E2=80=94=20parse?= =?UTF-8?q?=20retail's=20inline=20chat=20tag=20markup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Campaign CT slice A2. Pure parser, no UI, nothing wired yet. Retail's client composes chat lines with the markup already embedded — Handle_Communication__HearSpeech @0x005712A0 sprintf's it — and the text element recognises it while appending (UIElement_Text::InqGlyphs @0x00468EA0), calling TextTagFactory::MakeTag @0x00478480 per marker. A tagged speaker name arrives as: Dww<\Tell> tells you, "hello" ChatTagMarkup.Parse splits that into spans, consuming the markers: the name under a tag, the remainder untagged. The rule that decides where a tag ENDS is the one worth being careful about. It is the absence of a colon, not the backslash: MakeTag requires a ':' to succeed, so ANY bracketed text it cannot parse closes the open tag, and the backslash in retail's own closer (TextTag::BuildEndTag @0x00479190) is incidental to that. Porting "a closer starts with a backslash" would look correct on every retail line and then diverge on everything else, so the test pins all three of <\Tell>, and as closers. Two details taken from the decomp rather than guessed: only the FIRST colon of an IIDString payload separates the id from the name, so a name containing a colon survives intact (ParseStartTag @0x00478910); and an unterminated '<' is ordinary text, so a player typing "is 3 < 4 really" does not lose the rest of their sentence. The parse also upholds the contract CT-A1's draw side enforces — the concatenated span text always reproduces the visible line, because selection and hit-testing index into that flat string. Solution builds clean; full hermetic gate green. Note for the record: PreparedAssetVerificationCacheTests.BackupRecoveryHashes- TheBackupEvenWhenTheLiveCacheIsValid failed once during this slice's gate and then passed isolated, as a class, and on a full-gate rerun. This branch touches no launcher code, so it is load-sensitive rather than caused here — flagging it rather than silently re-running, since a test that only fails under parallel load is worth someone classifying. Co-Authored-By: Claude Opus 5 --- src/AcDream.Core/Chat/ChatTagMarkup.cs | 147 ++++++++++++++++++ .../Chat/ChatTagMarkupTests.cs | 118 ++++++++++++++ 2 files changed, 265 insertions(+) create mode 100644 src/AcDream.Core/Chat/ChatTagMarkup.cs create mode 100644 tests/AcDream.Core.Tests/Chat/ChatTagMarkupTests.cs diff --git a/src/AcDream.Core/Chat/ChatTagMarkup.cs b/src/AcDream.Core/Chat/ChatTagMarkup.cs new file mode 100644 index 00000000..edf77892 --- /dev/null +++ b/src/AcDream.Core/Chat/ChatTagMarkup.cs @@ -0,0 +1,147 @@ +namespace AcDream.Core.Chat; + +/// +/// One retail text tag: the TYPE, FORMAT and DATA of a +/// <TYPE:FORMAT:DATA> marker. +/// +/// +/// The tag kind, e.g. Tell. Retail resolves this through the DAT +/// EnumMapper (category 0x18) to the numeric type its colour rule keys +/// on; only Tell is confirmed in the build we target. +/// +/// +/// The payload shape, e.g. IIDString. Retail has four +/// (DID, IID, IIDEnum, IIDString) but only +/// IIDString has a listener. +/// +/// Everything after the second colon, unparsed. +public readonly record struct ChatTextTag(string Type, string Format, string Data) +{ + /// + /// The IIDString payload: an object id and a name. Returns + /// for any other format, or a malformed payload. + /// + /// + /// The name may itself contain colons, so only the FIRST colon separates + /// the id from the name — retail's + /// TextTag_IIDString::ParseStartTag @0x00478910 reads the id then + /// takes the remainder verbatim. + /// + public bool TryGetIidString(out uint objectId, out string name) + { + objectId = 0; + name = string.Empty; + if (!string.Equals(Format, "IIDString", StringComparison.Ordinal)) + return false; + + int split = Data.IndexOf(':'); + if (split <= 0 || split == Data.Length - 1) + return false; + if (!uint.TryParse( + Data.AsSpan(0, split), + System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, + out objectId)) + { + return false; + } + + name = Data[(split + 1)..]; + return true; + } +} + +/// One stretch of chat text, and the tag covering it (if any). +public readonly record struct ChatTextSpan(string Text, ChatTextTag? Tag); + +/// +/// Parses retail's inline chat tag markup into spans. +/// +/// +/// +/// Retail's client composes chat lines with the markup already embedded — +/// ClientCommunicationSystem::Handle_Communication__HearSpeech +/// @0x005712A0 sprintf's it — and the text element recognises it while +/// appending (UIElement_Text::InqGlyphs @0x00468EA0), calling +/// TextTagFactory::MakeTag @0x00478480 per marker. A tagged speaker +/// name arrives looking like: +/// +/// +/// <Tell:IIDString:1342177290:Dww>Dww<\Tell> tells you, "hello" +/// +/// +/// What closes a tag is the absence of a colon, not the backslash. +/// MakeTag requires a : to succeed, so ANY bracketed text it +/// cannot parse closes the open tag — the backslash in retail's own closer +/// (TextTag::BuildEndTag @0x00479190) is incidental to that rule, not +/// the mechanism. Porting "a closer starts with a backslash" would look right +/// on every retail line and then diverge on everything else. +/// +/// +public static class ChatTagMarkup +{ + /// + /// Splits into spans, consuming the markers. + /// + /// + /// An unterminated < (no closing > anywhere after it) + /// is ordinary text — there is no marker to consume. + /// + public static IReadOnlyList Parse(string? text) + { + if (string.IsNullOrEmpty(text)) + return Array.Empty(); + if (text.IndexOf('<') < 0) + return new[] { new ChatTextSpan(text, null) }; + + var spans = new List(); + ChatTextTag? open = null; + int runStart = 0; + + for (int i = 0; i < text.Length; i++) + { + if (text[i] != '<') + continue; + + int close = text.IndexOf('>', i + 1); + if (close < 0) + break; // unterminated: the rest is plain text + + // Flush the text before this marker under whatever tag was open. + if (i > runStart) + spans.Add(new ChatTextSpan(text[runStart..i], open)); + + open = TryParseStartTag(text[(i + 1)..close]); + runStart = close + 1; + i = close; + } + + if (runStart < text.Length) + spans.Add(new ChatTextSpan(text[runStart..], open)); + + return spans; + } + + /// + /// The marker's tag, or if it does not parse as one + /// — which is retail's close. + /// + private static ChatTextTag? TryParseStartTag(string inner) + { + int first = inner.IndexOf(':'); + if (first <= 0 || first == inner.Length - 1) + return null; + + int second = inner.IndexOf(':', first + 1); + if (second < 0) + { + // TYPE:FORMAT with no payload. Still a tag — the data is empty. + return new ChatTextTag(inner[..first], inner[(first + 1)..], string.Empty); + } + + return new ChatTextTag( + inner[..first], + inner[(first + 1)..second], + inner[(second + 1)..]); + } +} diff --git a/tests/AcDream.Core.Tests/Chat/ChatTagMarkupTests.cs b/tests/AcDream.Core.Tests/Chat/ChatTagMarkupTests.cs new file mode 100644 index 00000000..c5ff5046 --- /dev/null +++ b/tests/AcDream.Core.Tests/Chat/ChatTagMarkupTests.cs @@ -0,0 +1,118 @@ +using AcDream.Core.Chat; + +namespace AcDream.Core.Tests.Chat; + +/// +/// Retail's inline chat tag markup — the mechanism behind the green, clickable +/// speaker name. +/// +public sealed class ChatTagMarkupTests +{ + /// A retail tell line, exactly as the client composes it. + private const string TellLine = + @"Dww<\Tell> tells you, ""hello"""; + + [Fact] + public void ARetailTellLineSplitsIntoATaggedNameAndPlainRemainder() + { + IReadOnlyList spans = ChatTagMarkup.Parse(TellLine); + + Assert.Equal(2, spans.Count); + + Assert.Equal("Dww", spans[0].Text); + Assert.NotNull(spans[0].Tag); + Assert.Equal("Tell", spans[0].Tag!.Value.Type); + Assert.Equal("IIDString", spans[0].Tag!.Value.Format); + + Assert.Equal(@" tells you, ""hello""", spans[1].Text); + Assert.Null(spans[1].Tag); + } + + [Fact] + public void TheTagCarriesTheSpeakersIdAndName() + { + ChatTextTag tag = Assert + .Single(ChatTagMarkup.Parse(TellLine), s => s.Tag is not null) + .Tag!.Value; + + Assert.True(tag.TryGetIidString(out uint objectId, out string name)); + Assert.Equal(1342177290u, objectId); // 0x5000000A, an AC1 player id + Assert.Equal("Dww", name); + } + + [Fact] + public void AnyBracketedTextThatIsNotATagClosesTheOpenOne() + { + // This is the actual retail rule: MakeTag @0x00478480 needs a colon to + // succeed, and anything it cannot parse closes. The backslash in + // retail's own closer is incidental — a port that keyed on the + // backslash would look correct on every retail line and diverge on + // everything else, so all three of these must close. + foreach (string closer in new[] { @"<\Tell>", "", "" }) + { + IReadOnlyList spans = + ChatTagMarkup.Parse($"A{closer}B"); + + Assert.Equal(2, spans.Count); + Assert.Equal("A", spans[0].Text); + Assert.NotNull(spans[0].Tag); + Assert.Equal("B", spans[1].Text); + Assert.Null(spans[1].Tag); + } + } + + [Fact] + public void PlainTextIsOneUntaggedSpan() + { + ChatTextSpan span = Assert.Single(ChatTagMarkup.Parse("You say, \"hi\"")); + Assert.Equal("You say, \"hi\"", span.Text); + Assert.Null(span.Tag); + } + + [Fact] + public void AnUnterminatedBracketIsOrdinaryText() + { + // Nothing to consume, so it must survive verbatim rather than eating + // the rest of the player's sentence. + ChatTextSpan span = Assert.Single(ChatTagMarkup.Parse("is 3 < 4 really")); + Assert.Equal("is 3 < 4 really", span.Text); + Assert.Null(span.Tag); + } + + [Fact] + public void TheConcatenatedSpansAlwaysReproduceTheVisibleLine() + { + // The renderer draws spans but selects and hit-tests against the flat + // line, so the two must agree exactly (UiText.RunsMatchLine enforces + // the same contract on the draw side). + string visible = string.Concat( + ChatTagMarkup.Parse(TellLine).Select(s => s.Text)); + + Assert.Equal(@"Dww tells you, ""hello""", visible); + } + + [Theory] + [InlineData("x", "a non-numeric id")] + [InlineData("x", "no name")] + [InlineData("x", "empty payload")] + [InlineData("x", "a different format")] + public void AMalformedOrUnsupportedPayloadYieldsNoIidString( + string markup, string why) + { + ChatTextSpan span = ChatTagMarkup.Parse(markup)[0]; + Assert.NotNull(span.Tag); + Assert.False(span.Tag!.Value.TryGetIidString(out _, out _), why); + } + + [Fact] + public void ANameContainingAColonSurvivesIntact() + { + // Only the FIRST colon separates id from name; the rest is the name + // verbatim, matching ParseStartTag @0x00478910. + ChatTextTag tag = ChatTagMarkup.Parse("x")[0].Tag!.Value; + + Assert.True(tag.TryGetIidString(out uint id, out string name)); + Assert.Equal(5u, id); + Assert.Equal("Odd:Name", name); + } +} From 44fb74f8a60759499c3d9ed7a502f2185236fe65 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 07:27:14 +0200 Subject: [PATCH 11/43] =?UTF-8?q?feat(chat):=20CT-A3=20=E2=80=94=20a=20cha?= =?UTF-8?q?t=20line=20keeps=20the=20identity=20of=20who=20said=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Campaign CT slice A3. Still nothing visible; this removes the blocker. ChatEntry carried Sender and SenderGuid the whole way through ChatLog, and RecentLinesDetailed dropped both one step before the transcript. That is why a speaker's name could never be coloured or clicked apart from the sentence around it — by the time anything could draw it, there was no name left, only prose. FormattedLine now carries a Spans sidecar: the line split into stretches, one of which may hold a retail text tag. Text remains the VISIBLE line with markup consumed, so wrapping, selection, hit-testing and the caret are untouched, and Spans is null for the ordinary single-colour line, which is most of them. FormatEntryTagged shares its format strings with FormatEntry through a sender decorator rather than duplicating retail's wording. Two copies would be two things to keep in step, and these two renderings MUST show the same characters: the transcript selects against the flat text, so any drift would mean clicking one character and selecting another. Tagging is gated on AC1's player id range (0x50000001..0x6FFFFFFF), read off the guard in Handle_Communication__HearSpeech @0x005712A0 — so monsters and NPCs never become clickable, which a "has a name" test would get wrong. One real bug came out of a defensive test rather than a report. A sender name containing '<' re-parsed as a marker and SWALLOWED characters from the visible line ("Od --- .../Panels/Chat/ChatVM.cs | 111 ++++++++++++++++-- .../Panels/Chat/ChatVmTaggedSenderTests.cs | 107 +++++++++++++++++ 2 files changed, 209 insertions(+), 9 deletions(-) create mode 100644 tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVmTaggedSenderTests.cs diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs index 3aca8999..5c17d437 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs @@ -259,7 +259,66 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback /// Format a single for display. Public so tests /// can assert the per-kind formatting without touching a full log. /// - public static string FormatEntry(ChatEntry entry) => entry.Kind switch + public static string FormatEntry(ChatEntry entry) + => FormatEntry(entry, static sender => sender); + + /// + /// The lowest and highest object ids retail treats as a player, and + /// therefore the only senders it makes clickable. + /// + /// + /// AC1's dynamic/player id range, read off the guard in + /// Handle_Communication__HearSpeech @0x005712A0: outside it, retail + /// emits the sender's name as plain text with no tag at all. Monsters and + /// NPCs therefore never become clickable, which is the behaviour we want + /// and would not get from a "has a name" test. + /// + private const uint FirstPlayerObjectId = 0x50000001u; + private const uint LastPlayerObjectId = 0x6FFFFFFFu; + + /// + /// Formats an entry with retail's tag markup around the sender's name, + /// when that sender is a player. + /// + /// + /// Shares its format strings with + /// through the sender decorator, deliberately: two copies of retail's + /// wording would be two things to keep in step, and the plain and tagged + /// renderings of a line MUST show the same characters — the transcript + /// selects and hit-tests against the flat text. + /// + public static string FormatEntryTagged(ChatEntry entry) + => ShouldTagSender(entry) + ? FormatEntry( + entry, + sender => + $"{sender}<\\Tell>") + : FormatEntry(entry); + + /// Whether this entry's sender is a clickable player. + /// + /// A name containing a markup delimiter is deliberately NOT tagged. The + /// markup has no escape mechanism — retail's has none either, because AC + /// name validation makes the case unreachable there — so a name like + /// Od<d would be re-parsed as a marker and SWALLOW characters + /// out of the visible line. Sender names are server data, so the guard + /// stays: the line renders plain, exactly as it does for any other + /// untaggable sender, instead of rendering corrupted. + /// + internal static bool ShouldTagSender(ChatEntry entry) + => entry.SenderGuid >= FirstPlayerObjectId + && entry.SenderGuid <= LastPlayerObjectId + && !string.IsNullOrEmpty(entry.Sender) + && entry.Sender.IndexOf('<') < 0 + && entry.Sender.IndexOf('>') < 0 + && !IsOwnSpeaker(entry.Sender) + && entry.Kind is ChatKind.LocalSpeech + or ChatKind.RangedSpeech + or ChatKind.Channel + or ChatKind.Tell; + + private static string FormatEntry( + ChatEntry entry, Func decorateSender) => entry.Kind switch { // Retail style: "Name says, \"text\"" (incoming) / // "You say, \"text\"" (own echo). Sender is "" for an @@ -268,10 +327,10 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback // collapse to the singular "You say" verb here. ChatKind.LocalSpeech => IsOwnSpeaker(entry.Sender) ? $"You say, \"{entry.Text}\"" - : $"{entry.Sender} says, \"{entry.Text}\"", + : $"{decorateSender(entry.Sender)} says, \"{entry.Text}\"", ChatKind.RangedSpeech => IsOwnSpeaker(entry.Sender) ? $"You shout, \"{entry.Text}\"" - : $"{entry.Sender} shouts, \"{entry.Text}\"", + : $"{decorateSender(entry.Sender)} shouts, \"{entry.Text}\"", // Channel: "[ChannelName] Sender says, \"text\"". ChannelName // is populated by callers that know the friendly name (the // TurbineChat inbound dispatch and OnSelfSent for Channel @@ -283,12 +342,12 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback // empty so the formatter substitutes here). ChatKind.Channel => IsOwnSpeaker(entry.Sender) ? $"[{ChannelLabel(entry)}] You say, \"{entry.Text}\"" - : $"[{ChannelLabel(entry)}] {entry.Sender} says, \"{entry.Text}\"", + : $"[{ChannelLabel(entry)}] {decorateSender(entry.Sender)} says, \"{entry.Text}\"", // Tell: SenderGuid != 0 means an incoming whisper; == 0 is the // OnSelfSent echo where Sender carries the target name. Retail // wording: "You tell Caith, \"hi\"" / "Caith tells you, \"hi\"". ChatKind.Tell => entry.SenderGuid != 0 - ? $"{entry.Sender} tells you, \"{entry.Text}\"" + ? $"{decorateSender(entry.Sender)} tells you, \"{entry.Text}\"" : $"You tell {entry.Sender}, \"{entry.Text}\"", // Campaign CH user-gate round 1 (item B): retail prints system text // bare, with no "[System]" prefix — that prefix was acdream's own @@ -358,14 +417,39 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback for (int i = 0; i < count; i++) { var entry = snap[start + i]; - string text = FormatEntry(entry); + + // Compose with retail's tag markup, then split it. Text stays the + // VISIBLE line (markup consumed), so every existing consumer — + // wrapping, selection, hit-testing, the caret — is unaffected; + // Spans is the sidecar that remembers which stretch was the + // speaker's name. Campaign CT slice A3: this is the point where + // sender identity used to die. + string markup = FormatEntryTagged(entry); + IReadOnlyList? spans = ShouldTagSender(entry) + ? ChatTagMarkup.Parse(markup) + : null; + string text = spans is null + ? markup + : string.Concat(spans.Select(span => span.Text)); + if (timestamps) - text = ChatLog.FormatTimestampPrefix(entry.Received) + text; + { + string prefix = ChatLog.FormatTimestampPrefix(entry.Received); + text = prefix + text; + // The prefix is its own untagged stretch in front, so the + // spans keep lining up with the visible text. + if (spans is not null) + spans = new[] { new ChatTextSpan(prefix, null) } + .Concat(spans) + .ToArray(); + } + lines[i] = new FormattedLine( Text: text, Kind: entry.Kind, CombatKind: entry.CombatKind, - LogTextType: entry.LogTextType); + LogTextType: entry.LogTextType, + Spans: spans); } return lines; } @@ -381,8 +465,17 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback /// Campaign CH slice CH1: the retail wire LogTextType that keys /// — see . /// +/// +/// Campaign CT slice A3: the line split into stretches, where a stretch may +/// carry a retail text tag (a clickable speaker name). +/// for the ordinary single-colour line, which is most of them. +/// Invariant: concatenating the span text reproduces +/// exactly — the transcript wraps, selects and +/// hit-tests against that flat string. +/// public readonly record struct FormattedLine( string Text, ChatKind Kind, CombatLineKind? CombatKind, - uint LogTextType); + uint LogTextType, + IReadOnlyList? Spans = null); diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVmTaggedSenderTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVmTaggedSenderTests.cs new file mode 100644 index 00000000..7b171a47 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVmTaggedSenderTests.cs @@ -0,0 +1,107 @@ +using AcDream.Core.Chat; +using AcDream.UI.Abstractions.Panels.Chat; + +namespace AcDream.UI.Abstractions.Tests.Panels.Chat; + +/// +/// Campaign CT slice A3: a chat line keeps the identity of who said it, all +/// the way to the renderer. +/// +/// +/// ChatEntry carried Sender and SenderGuid the whole way +/// through ChatLog, and RecentLinesDetailed then dropped both one +/// step before the transcript — which is why a speaker's name could never be +/// coloured or clicked separately from the sentence around it. +/// +public sealed class ChatVmTaggedSenderTests +{ + private const uint PlayerGuid = 0x5000000Au; + + private static ChatEntry Speech( + string sender, string text, uint senderGuid, ChatKind kind = ChatKind.LocalSpeech) + => new(kind, sender, text, senderGuid, 0u); + + [Fact] + public void APlayersNameBecomesItsOwnSpan() + { + string markup = ChatVM.FormatEntryTagged(Speech("Dww", "hello", PlayerGuid)); + IReadOnlyList spans = ChatTagMarkup.Parse(markup); + + Assert.Equal("Dww", spans[0].Text); + Assert.NotNull(spans[0].Tag); + Assert.True(spans[0].Tag!.Value.TryGetIidString(out uint id, out string name)); + Assert.Equal(PlayerGuid, id); + Assert.Equal("Dww", name); + } + + [Fact] + public void TheTaggedLineShowsExactlyTheSameCharactersAsThePlainOne() + { + // The whole design rests on this: the transcript wraps, selects and + // hit-tests against the flat text, so markup must be invisible in the + // result. If these ever diverge, clicks land on the wrong characters. + ChatEntry entry = Speech("Dww", "hello", PlayerGuid); + + string plain = ChatVM.FormatEntry(entry); + string visible = string.Concat( + ChatTagMarkup.Parse(ChatVM.FormatEntryTagged(entry)).Select(s => s.Text)); + + Assert.Equal(plain, visible); + Assert.Equal("Dww says, \"hello\"", visible); + } + + [Theory] + [InlineData(ChatKind.LocalSpeech)] + [InlineData(ChatKind.RangedSpeech)] + [InlineData(ChatKind.Channel)] + [InlineData(ChatKind.Tell)] + public void EveryKindThatNamesASpeakerTagsIt(ChatKind kind) + => Assert.True(ChatVM.ShouldTagSender(Speech("Dww", "hi", PlayerGuid, kind))); + + [Fact] + public void NonPlayerSendersAreNeverTagged() + { + // Retail gates on AC1's player id range, so a monster or NPC speaking + // never becomes clickable. A "has a name" test would tag them. + Assert.False(ChatVM.ShouldTagSender(Speech("A Drudge", "hi", 0x80000001u))); + Assert.False(ChatVM.ShouldTagSender(Speech("A Drudge", "hi", 0x4FFFFFFFu))); + + // Boundaries of the range itself. + Assert.True(ChatVM.ShouldTagSender(Speech("P", "hi", 0x50000001u))); + Assert.True(ChatVM.ShouldTagSender(Speech("P", "hi", 0x6FFFFFFFu))); + Assert.False(ChatVM.ShouldTagSender(Speech("P", "hi", 0x70000000u))); + } + + [Fact] + public void OurOwnLinesAndUnnamedSendersAreNotTagged() + { + // No guid at all (our own echo), and the substituted "You". + Assert.False(ChatVM.ShouldTagSender(Speech("Dww", "hi", 0u))); + Assert.False(ChatVM.ShouldTagSender(Speech("You", "hi", PlayerGuid))); + Assert.False(ChatVM.ShouldTagSender(Speech(string.Empty, "hi", PlayerGuid))); + } + + [Fact] + public void AnUntaggedLineIsFormattedExactlyAsBefore() + { + // The overwhelming majority of lines take this path; it must be + // byte-identical to the pre-CT formatting. + ChatEntry system = new(ChatKind.System, string.Empty, "Welcome.", 0u, 0u); + + Assert.Equal(ChatVM.FormatEntry(system), ChatVM.FormatEntryTagged(system)); + Assert.Equal("Welcome.", ChatVM.FormatEntryTagged(system)); + } + + [Fact] + public void ANameContainingMarkupCharactersStillRoundTrips() + { + // Defensive: a name is server data. Whatever it contains, the visible + // line must still equal the plain formatting, or selection desyncs. + ChatEntry entry = Speech("Od s.Text)); + + Assert.Equal(ChatVM.FormatEntry(entry), visible); + } +} From 53395e4de4232131ea6da7aeb2de8f4ce6b36dff Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 07:31:20 +0200 Subject: [PATCH 12/43] =?UTF-8?q?feat(chat):=20CT-A4=20=E2=80=94=20speaker?= =?UTF-8?q?=20names=20render=20in=20retail's=20tag=20colour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Campaign CT slice A4, and the first slice of Group A that shows on screen: a player's name in a chat line now draws green while the rest of the line keeps its message colour. The colour is AUTHORED, not computed. Retail keeps two parallel index-selected colour arrays on the text element and refreshes both from the same caller index on every append (UIElement_Text::AppendStringInfoWithFont @0x00469DE0): property 0x1B for ordinary glyphs, 0x1D for glyphs under an open tag. Property 0x1D is read exactly the way 0x1B already was, carried on ElementInfo, and seeded onto UiText beside DefaultColor. Measured on the chat transcript (0x2100006F / 0x10000011) as RGB(0,178,0). It deliberately does NOT go into RetailChatColorTable. That table is the runtime-built per-LogTextType mapping; the tag colour is per-element authored data, and filing it there would put it somewhere it would look right in tests and be wrong in principle. RunsForFragment is the load-bearing piece and is pure. Wrapping can drop the space it broke on, so a fragment is NOT simply the next N characters of the line — BuildLines locates each fragment in the source text to keep the span offsets honest, and the mapper clips spans to the fragment window. A tag straddling a wrap break is therefore split across both fragments and stays green on both, instead of changing colour mid-word. Two guards worth naming. A fragment containing no tag returns NULL rather than a single-run list, so the overwhelming majority of lines keep the existing flat draw path untouched. And an element authoring no 0x1D falls back to the line colour, so a name never renders in a colour nobody chose. The run/fragment contract is property-tested across every substring of a tell line, because CT-A1's RunsMatchLine refuses mismatched runs by silently falling back to flat text — a mapping bug here would degrade quietly rather than fail. Solution builds clean; full hermetic gate green. Co-Authored-By: Claude Opus 5 --- .../UI/Layout/ChatTranscriptRenderer.cs | 87 +++++++++- .../UI/Layout/ChatWindowController.cs | 27 ++- src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 2 + src/AcDream.App/UI/Layout/ElementReader.cs | 36 ++++ src/AcDream.App/UI/UiText.cs | 7 + .../UI/Layout/ChatTranscriptRunsTests.cs | 161 ++++++++++++++++++ 6 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs diff --git a/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs b/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs index f712e189..9ee09e71 100644 --- a/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs +++ b/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Numerics; +using AcDream.Core.Chat; using AcDream.UI.Abstractions.Panels.Chat; namespace AcDream.App.UI.Layout; @@ -64,14 +65,66 @@ internal static class ChatTranscriptRenderer /// unchanged" rule — see 's own doc). /// Callers pass their transcript's . /// + /// + /// The runs covering one wrapped fragment, or when + /// the fragment is a single colour. + /// + /// + /// + /// Campaign CT slice A4. Wrapping splits a line into fragments, and a tag + /// can straddle a break, so a fragment may hold part of a tagged run, all + /// of it, or none. + /// + /// + /// Returns null unless a tag actually falls inside the window — a + /// single-colour fragment must take the ordinary flat draw path rather + /// than a one-run list that means the same thing. + /// + /// + internal static IReadOnlyList? RunsForFragment( + IReadOnlyList spans, + int fragmentStart, + int fragmentLength, + Vector4 lineColor, + Vector4 tagColor) + { + int fragmentEnd = fragmentStart + fragmentLength; + var runs = new List(); + bool sawTag = false; + int at = 0; + + foreach (ChatTextSpan span in spans) + { + int spanStart = at; + int spanEnd = at + span.Text.Length; + at = spanEnd; + + int from = Math.Max(spanStart, fragmentStart); + int to = Math.Min(spanEnd, fragmentEnd); + if (to <= from) + continue; + + bool tagged = span.Tag is not null; + sawTag |= tagged; + runs.Add(new UiText.TextRun( + span.Text.Substring(from - spanStart, to - from), + tagged ? tagColor : lineColor)); + } + + return sawTag ? runs : null; + } + public static List BuildLines( IReadOnlyList detailed, float maxW, Func measure, Func? accept, - Vector4 defaultColor) + Vector4 defaultColor, + Vector4? tagColor = null, + List?>? runsPerLine = null) { var result = new List(detailed.Count); + runsPerLine?.Clear(); if (detailed.Count == 0) return result; @@ -88,8 +141,40 @@ internal static class ChatTranscriptRenderer continue; if (RetailChatColorTable.TryGetColor(d.LogTextType, out Vector4 resolved)) currentColor = resolved; + // Wrapping can DROP the space it broke on, so a fragment is not + // simply the next N characters — locate each one in the source + // line to keep the span offsets honest. + int searchFrom = 0; foreach (string frag in WrapText(d.Text, maxW, measure)) + { result.Add(new UiText.Line(frag, currentColor)); + + if (runsPerLine is null) + continue; + + if (d.Spans is not { Count: > 0 } spans || frag.Length == 0) + { + runsPerLine.Add(null); + continue; + } + + int at = d.Text.IndexOf(frag, searchFrom, StringComparison.Ordinal); + if (at < 0) + { + // Should not happen; a fragment always comes from the line. + // Fall back to the flat colour rather than mis-colouring. + runsPerLine.Add(null); + continue; + } + searchFrom = at + frag.Length; + + runsPerLine.Add(RunsForFragment( + spans, + at, + frag.Length, + currentColor, + tagColor ?? currentColor)); + } } return result; } diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index da8e21df..c000034e 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -128,6 +128,13 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta // metrics that determine wrapping change. This makes an idle chat window // allocation-free instead of snapshotting/formatting/wrapping every frame. private IReadOnlyList _cachedTranscriptLines = Array.Empty(); + + /// + /// Per-cached-line runs, index-aligned with + /// . Null at an index means that line + /// is a single colour and draws through the ordinary flat path. + /// + private readonly List?> _cachedTranscriptRuns = new(); private long _cachedTranscriptRevision = -1; private ulong _cachedFilter; private float _cachedTranscriptWrapWidth = float.NaN; @@ -325,6 +332,14 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta // fallback — a flat overlay here only mismatched it under the wrong // (0x21000006) layout, whose transcript panel lacked one. c.Transcript.LinesProvider = () => c.GetTranscriptLines(vm); + // Index-aligned with the lines the provider above returns, and read + // from the same cache — so a tagged speaker name draws in the + // element's authored tag colour while the rest of its line keeps the + // message colour. + c.Transcript.LineRunsProvider = index => + index >= 0 && index < c._cachedTranscriptRuns.Count + ? c._cachedTranscriptRuns[index] + : null; // ── Input ──────────────────────────────────────────────────────── // Editable/selectable/one-line semantics and state sprites came from the @@ -775,8 +790,18 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta // no more accept:null "no user filter" placeholder. bool Accept(uint logTextType) => _windowFilters.ShouldDisplay( ChatWindowState.MainWindowId, ChatWindowState.BroadcastTargetWindow, logTextType); + // Campaign CT slice A4: the runs come back alongside the flat lines and + // are cached with them, so the transcript's per-line run lookup costs + // nothing per frame — it reads the same cache the lines do. + _cachedTranscriptRuns.Clear(); var result = ChatTranscriptRenderer.BuildLines( - detailed, maxW, measure, Accept, Transcript.DefaultColor); + detailed, + maxW, + measure, + Accept, + Transcript.DefaultColor, + Transcript.TagColor, + _cachedTranscriptRuns); return StoreTranscriptLayout(result, revision, filter, maxW, datFont, debugFont); } diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 9713c159..bb12f3fb 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -845,6 +845,8 @@ public static class DatWidgetFactory // the build-time default. if (info.FontColor.HasValue) t.DefaultColor = info.FontColor.Value; + if (info.TagFontColor.HasValue) + t.TagColor = info.TagFontColor.Value; // Outline color from dat property 0x22 (ColorBaseProperty). Only 9 elements in the // whole DAT set author a non-black value; when absent, UiText's own ctor default diff --git a/src/AcDream.App/UI/Layout/ElementReader.cs b/src/AcDream.App/UI/Layout/ElementReader.cs index 2cf514f1..cd335cc6 100644 --- a/src/AcDream.App/UI/Layout/ElementReader.cs +++ b/src/AcDream.App/UI/Layout/ElementReader.cs @@ -131,6 +131,22 @@ public sealed class ElementInfo /// public Vector4? FontColor; + /// + /// Authored TAG font colour (dat property 0x1D), the colour retail + /// gives a tagged glyph run — a clickable speaker name — as distinct from + /// (0x1B) for everything else. + /// + /// + /// The two are parallel index-selected arrays refreshed from the same + /// caller index on every append + /// (UIElement_Text::AppendStringInfoWithFont @0x00469DE0), and a + /// glyph takes this one only while a tag is open. Measured on the chat + /// transcript (0x2100006F / 0x10000011) as RGB(0,178,0); it is AUTHORED + /// per element, not built by the runtime chat colour table, so it does not + /// belong in RetailChatColorTable. + /// + public Vector4? TagFontColor; + /// /// Outline flag from dat Properties[0x21] (BoolBaseProperty). Retail /// UIElement_Text::SetOutline @0x0046a81c / m_bitField & 0x10. @@ -576,6 +592,7 @@ public static class ElementReader // FontColor: derived wins when it has an explicit (non-null) color; otherwise inherit the base. // Null means "dat carried no 0x1B property" — so null-derived does NOT override a non-null base. FontColor = derived.FontColor ?? base_.FontColor, + TagFontColor = derived.TagFontColor ?? base_.TagFontColor, // Outline: derived wins when true (the dat property 0x21 was present and read as // true); otherwise inherit the base. False-derived never overrides a true base — // matching the FontDid/HJustify "non-default wins" convention. @@ -669,6 +686,25 @@ public static class ElementReader } } + // Tag font colour (0x1D) — same shape as 0x1B above, read the same way. + if (info.TryGetEffectiveProperty(0x1Du, out var tagColor)) + { + UiPropertyValue? tagValue = tagColor.Kind == UiPropertyKind.Color + ? tagColor + : tagColor.Kind == UiPropertyKind.Array + && tagColor.ArrayValue.Count > 0 + && tagColor.ArrayValue[0].Kind == UiPropertyKind.Color + ? tagColor.ArrayValue[0] + : null; + if (tagValue is not null) + { + var t = tagValue.ColorValue; + float alpha = t.Alpha == 0 ? 1f : t.Alpha / 255f; + info.TagFontColor = + new Vector4(t.Red / 255f, t.Green / 255f, t.Blue / 255f, alpha); + } + } + // Outline (0x21): BoolBaseProperty. Retail SetOutline @0x0046a81c / m_bitField & 0x10. if (info.TryGetEffectiveProperty(0x21u, out var outline) && outline.Kind == UiPropertyKind.Bool) diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs index 8c228105..9e7736a4 100644 --- a/src/AcDream.App/UI/UiText.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -167,6 +167,13 @@ public sealed class UiText : UiElement, IUiDatStateful /// public Vector4 DefaultColor { get; set; } = Vector4.One; + /// + /// Colour for a TAGGED run (dat property 0x1D) — retail's clickable + /// speaker name. Falls back to when the element + /// authors none. + /// + public Vector4? TagColor { get; set; } + /// /// Authored UIElement_Text font-color list from LayoutDesc property /// 0x1B. Retail AppendTextWithFont @ 0x00469D70 selects an diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs new file mode 100644 index 00000000..15fe1d4b --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Core.Chat; +using AcDream.UI.Abstractions.Panels.Chat; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Campaign CT slice A4: a tagged speaker name draws in the element's authored +/// TAG colour while the rest of its line keeps the message colour. +/// +public sealed class ChatTranscriptRunsTests +{ + private static readonly Vector4 LineColor = new(0.8f, 0.8f, 0.8f, 1f); + + /// Measured from the installed dats: chat 0x2100006F / 0x10000011. + private static readonly Vector4 TagGreen = new(0f, 178f / 255f, 0f, 1f); + + /// 10px per character, so wrap points are predictable. + private static float Measure(string text) => text.Length * 10f; + + private static IReadOnlyList TellSpans(string name, string rest) + => new[] + { + new ChatTextSpan(name, new ChatTextTag("Tell", "IIDString", $"1342177290:{name}")), + new ChatTextSpan(rest, null), + }; + + [Fact] + public void TheNameTakesTheTagColourAndTheRestTakesTheLineColour() + { + IReadOnlyList? runs = ChatTranscriptRenderer.RunsForFragment( + TellSpans("Dww", " tells you"), + fragmentStart: 0, + fragmentLength: "Dww tells you".Length, + LineColor, + TagGreen); + + Assert.NotNull(runs); + Assert.Equal(2, runs!.Count); + Assert.Equal(("Dww", TagGreen), (runs[0].Text, runs[0].Color)); + Assert.Equal((" tells you", LineColor), (runs[1].Text, runs[1].Color)); + } + + [Fact] + public void AFragmentWithNoTagInItGetsNoRunsAtAll() + { + // A single-colour fragment must take the ordinary flat draw path + // rather than a one-run list that means the same thing. + IReadOnlyList spans = TellSpans("Dww", " tells you, hello there"); + + Assert.Null(ChatTranscriptRenderer.RunsForFragment( + spans, + fragmentStart: 10, // well past the name + fragmentLength: 5, + LineColor, + TagGreen)); + } + + [Fact] + public void ATagStraddlingAWrapBreakIsSplitAcrossBothFragments() + { + // The name is long enough to be cut in half by the wrap; both halves + // must still be green, or a name would change colour mid-word. + IReadOnlyList spans = TellSpans("Bartholomew", " tells you"); + + IReadOnlyList? first = ChatTranscriptRenderer.RunsForFragment( + spans, fragmentStart: 0, fragmentLength: 5, LineColor, TagGreen); + IReadOnlyList? second = ChatTranscriptRenderer.RunsForFragment( + spans, fragmentStart: 5, fragmentLength: 6, LineColor, TagGreen); + + Assert.Equal("Barth", Assert.Single(first!).Text); + Assert.Equal(TagGreen, first![0].Color); + Assert.Equal("olomew", Assert.Single(second!).Text); + Assert.Equal(TagGreen, second![0].Color); + } + + [Fact] + public void RunsAlwaysReproduceTheFragmentTheyCover() + { + // UiText.RunsMatchLine refuses to draw runs that disagree with the + // line, so a mapping bug here would silently fall back to flat text + // rather than fail loudly. Assert the contract directly. + IReadOnlyList spans = TellSpans("Dww", " tells you, \"hi\""); + string line = string.Concat(spans.Select(s => s.Text)); + + for (int start = 0; start < line.Length; start++) + { + for (int len = 1; len <= line.Length - start; len++) + { + IReadOnlyList? runs = + ChatTranscriptRenderer.RunsForFragment( + spans, start, len, LineColor, TagGreen); + if (runs is null) + continue; + + string fragment = line.Substring(start, len); + Assert.True( + UiText.RunsMatchLine(runs, fragment), + $"runs disagree with fragment [{start}..{start + len})"); + } + } + } + + [Fact] + public void BuildLines_EmitsOneRunEntryPerWrappedFragment() + { + // The run list is index-aligned with the lines the transcript draws; + // if the two ever fall out of step, names colour on the wrong rows. + var detailed = new List + { + new("Dww tells you, hello there friend", ChatKind.Tell, null, 0x03u, + TellSpans("Dww", " tells you, hello there friend")), + new("Welcome.", ChatKind.System, null, 0x05u), + }; + var runs = new List?>(); + + List lines = ChatTranscriptRenderer.BuildLines( + detailed, + maxW: 150f, // forces the first entry to wrap + Measure, + accept: null, + defaultColor: LineColor, + tagColor: TagGreen, + runsPerLine: runs); + + Assert.True(lines.Count > 2, "the first line should have wrapped"); + Assert.Equal(lines.Count, runs.Count); + + // The very first fragment holds the name, so it is the one with runs. + Assert.NotNull(runs[0]); + Assert.Equal(TagGreen, runs[0]![0].Color); + + // The untagged system line never gets runs. + Assert.Null(runs[^1]); + } + + [Fact] + public void BuildLines_WithoutATagColourFallsBackToTheLineColour() + { + // An element that authors no 0x1D must not render the name in a + // colour nobody chose; it renders like the rest of the line. + var detailed = new List + { + new("Dww tells you", ChatKind.Tell, null, 0x03u, + TellSpans("Dww", " tells you")), + }; + var runs = new List?>(); + + ChatTranscriptRenderer.BuildLines( + detailed, maxW: 1000f, Measure, accept: null, + defaultColor: LineColor, tagColor: null, runsPerLine: runs); + + IReadOnlyList line = Assert.Single(runs)!; + Assert.All(line, run => Assert.Equal(line[0].Color, run.Color)); + } +} From d32ef388f053c339ccd23dc7a6e19cd7f1a03777 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 07:34:58 +0200 Subject: [PATCH 13/43] =?UTF-8?q?feat(chat):=20CT-A5=20=E2=80=94=20clickin?= =?UTF-8?q?g=20a=20speaker's=20name=20opens=20a=20tell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Campaign CT slice A5, closing Group A. Retail's gmMainChatUI::RecvNotice_TextTag_IIDStringClick @0x004CCE10 -> ChatInterface::StartTell @0x004F41F0 writes "@tell {Name}, " into the chat entry and takes keyboard focus; clicking a green name here now does the same. The trailing space is deliberate — without it the first character the player types joins the comma. Three seams, each narrow on purpose: - UiText.OnCharClick is offered the character under a left click before the element-wide OnClick, and consuming it suppresses that. Kept separate because a tag click is POSITIONAL and an element click is not; folding them together would make every text element with an OnClick swallow tag clicks. - TaggedRangesForFragment returns tagged column ranges relative to the FRAGMENT, because that is what a click resolves to — UiText.HitChar gives a line index into the WRAPPED list plus a column within it. Line-relative ranges would land every click on a wrapped line at the wrong characters. - The controller caches those ranges alongside the runs it already caches, so the per-click lookup reads the same cache the draw does. The hit test is half-open: a caret slot sits BETWEEN glyphs, so clicking just past a name's last letter belongs to the space after it, not the name. Pinned by theory rather than left to chance, since off-by-one here means clicking a name sometimes does nothing. StartTell uses the tag's NAME, not its object id — retail carries the id but this handler never reads it, so the tell still addresses correctly for someone who has since moved out of range. Group A is complete: names are green (A4) and clickable (A5). Ready for the user's visual gate. Solution builds clean; full hermetic gate green. Co-Authored-By: Claude Opus 5 --- .../UI/Layout/ChatTranscriptRenderer.cs | 54 ++++++++++-- .../UI/Layout/ChatWindowController.cs | 65 +++++++++++++- src/AcDream.App/UI/UiText.cs | 29 ++++++- .../UI/Layout/ChatTagClickTests.cs | 85 +++++++++++++++++++ .../UI/Layout/ChatWindowControllerTests.cs | 19 +++++ 5 files changed, 245 insertions(+), 7 deletions(-) create mode 100644 tests/AcDream.App.Tests/UI/Layout/ChatTagClickTests.cs diff --git a/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs b/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs index 9ee09e71..2b4a1a5d 100644 --- a/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs +++ b/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs @@ -114,6 +114,45 @@ internal static class ChatTranscriptRenderer return sawTag ? runs : null; } + /// + /// The tagged column ranges inside one wrapped fragment, or + /// when it holds none. + /// + /// + /// Columns are relative to the FRAGMENT, because that is what a click + /// resolves to: UiText.HitChar returns a line index into the + /// wrapped list plus a column within that line. + /// + internal static IReadOnlyList<(int Start, int Length, ChatTextTag Tag)>? + TaggedRangesForFragment( + IReadOnlyList spans, + int fragmentStart, + int fragmentLength) + { + int fragmentEnd = fragmentStart + fragmentLength; + List<(int Start, int Length, ChatTextTag Tag)>? ranges = null; + int at = 0; + + foreach (ChatTextSpan span in spans) + { + int spanStart = at; + int spanEnd = at + span.Text.Length; + at = spanEnd; + + if (span.Tag is not { } tag) + continue; + + int from = Math.Max(spanStart, fragmentStart); + int to = Math.Min(spanEnd, fragmentEnd); + if (to <= from) + continue; + + (ranges ??= new()).Add((from - fragmentStart, to - from, tag)); + } + + return ranges; + } + public static List BuildLines( IReadOnlyList detailed, float maxW, @@ -121,10 +160,12 @@ internal static class ChatTranscriptRenderer Func? accept, Vector4 defaultColor, Vector4? tagColor = null, - List?>? runsPerLine = null) + List?>? runsPerLine = null, + List?>? tagsPerLine = null) { var result = new List(detailed.Count); runsPerLine?.Clear(); + tagsPerLine?.Clear(); if (detailed.Count == 0) return result; @@ -149,12 +190,13 @@ internal static class ChatTranscriptRenderer { result.Add(new UiText.Line(frag, currentColor)); - if (runsPerLine is null) + if (runsPerLine is null && tagsPerLine is null) continue; if (d.Spans is not { Count: > 0 } spans || frag.Length == 0) { - runsPerLine.Add(null); + runsPerLine?.Add(null); + tagsPerLine?.Add(null); continue; } @@ -163,17 +205,19 @@ internal static class ChatTranscriptRenderer { // Should not happen; a fragment always comes from the line. // Fall back to the flat colour rather than mis-colouring. - runsPerLine.Add(null); + runsPerLine?.Add(null); + tagsPerLine?.Add(null); continue; } searchFrom = at + frag.Length; - runsPerLine.Add(RunsForFragment( + runsPerLine?.Add(RunsForFragment( spans, at, frag.Length, currentColor, tagColor ?? currentColor)); + tagsPerLine?.Add(TaggedRangesForFragment(spans, at, frag.Length)); } } return result; diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index c000034e..657a6353 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -135,6 +135,14 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta /// is a single colour and draws through the ordinary flat path. /// private readonly List?> _cachedTranscriptRuns = new(); + + /// + /// Per-cached-line tagged column ranges, index-aligned with + /// . This is what turns a click into a + /// tag; the runs alongside only decide colour. + /// + private readonly List?> + _cachedTranscriptTags = new(); private long _cachedTranscriptRevision = -1; private ulong _cachedFilter; private float _cachedTranscriptWrapWidth = float.NaN; @@ -340,6 +348,10 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta index >= 0 && index < c._cachedTranscriptRuns.Count ? c._cachedTranscriptRuns[index] : null; + // Clicking a tagged name opens a tell to that player, exactly as + // retail does — gmMainChatUI::RecvNotice_TextTag_IIDStringClick + // @0x004CCE10 -> ChatInterface::StartTell @0x004F41F0. + c.Transcript.OnCharClick = pos => c.TryStartTellFromTag(pos); // ── Input ──────────────────────────────────────────────────────── // Editable/selectable/one-line semantics and state sprites came from the @@ -794,6 +806,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta // are cached with them, so the transcript's per-line run lookup costs // nothing per frame — it reads the same cache the lines do. _cachedTranscriptRuns.Clear(); + _cachedTranscriptTags.Clear(); var result = ChatTranscriptRenderer.BuildLines( detailed, maxW, @@ -801,10 +814,60 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta Accept, Transcript.DefaultColor, Transcript.TagColor, - _cachedTranscriptRuns); + _cachedTranscriptRuns, + _cachedTranscriptTags); return StoreTranscriptLayout(result, revision, filter, maxW, datFont, debugFont); } + /// + /// Opens a tell to the player whose name was clicked, if one was. + /// + /// + /// Retail writes "@tell {Name}, " into the chat entry, takes keyboard + /// focus and shows the entry bar (ChatInterface::StartTell + /// @0x004F41F0). It uses the tag's NAME rather than its object id: the + /// id is carried but this handler never reads it, so a tell still + /// addresses correctly for a player who has since moved out of range. + /// + internal bool TryStartTellFromTag(UiText.Pos position) + { + if (position.Line < 0 || position.Line >= _cachedTranscriptTags.Count) + return false; + if (_cachedTranscriptTags[position.Line] is not { } ranges) + return false; + + foreach ((int start, int length, ChatTextTag tag) in ranges) + { + // A caret slot sits BETWEEN glyphs, so the range is half-open: + // clicking just past the last letter of a name is not that name. + if (position.Col < start || position.Col >= start + length) + continue; + if (!tag.TryGetIidString(out _, out string name) || name.Length == 0) + continue; + + StartTell(name); + return true; + } + + return false; + } + + /// Aims the chat entry at and focuses it. + internal void StartTell(string name) + { + Input.SetText($"@tell {name}, "); + Input.MoveCaret(int.MaxValue); + FindRootOf(Input)?.SetKeyboardFocus(Input); + } + + private static UiRoot? FindRootOf(UiElement element) + { + for (UiElement? at = element; at is not null; at = at.Parent) + if (at is UiRoot root) + return root; + return null; + } + private IReadOnlyList StoreTranscriptLayout( IReadOnlyList lines, long revision, diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs index 9e7736a4..0c8ce38c 100644 --- a/src/AcDream.App/UI/UiText.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -42,7 +42,10 @@ public sealed class UiText : UiElement, IUiDatStateful private Action? _onClick; public override bool HandlesClick - => OnClick is not null || WheelScrollEnabled || base.HandlesClick; + => OnClick is not null + || OnCharClick is not null + || WheelScrollEnabled + || base.HandlesClick; /// Dat element id for imported UIElement_Text widgets. 0 for synthesized text. public uint ElementId { get; set; } @@ -58,6 +61,18 @@ public sealed class UiText : UiElement, IUiDatStateful /// character index (0..line.Text.Length, i.e. a caret slot between glyphs). public readonly record struct Pos(int Line, int Col); + /// + /// Offered the character under a left click before . + /// Return to consume the click. + /// + /// + /// The seam for retail's clickable tagged runs. Kept separate from + /// because a tag click is positional and an element + /// click is not; folding them together would make every text element with + /// an OnClick swallow tag clicks. + /// + public Func? OnCharClick { get; set; } + /// Provider of the lines to show, oldest-first. Polled each frame. public Func> LinesProvider { get; set; } = static () => Array.Empty(); @@ -909,6 +924,18 @@ public sealed class UiText : UiElement, IUiDatStateful public override bool OnEvent(in UiEvent e) { + // Campaign CT slice A5: a click inside the text may land on a TAGGED + // run (a speaker's name), which retail treats as its own affordance — + // UIElement_Text::MouseUp @0x004694F0 resolves the xy to a glyph and + // dispatches through that glyph's tag. Offered the character first; + // a handled tag click does not also fire the element-wide OnClick. + if (e.Type == UiEventType.Click && OnCharClick is not null) + { + // Data1/Data2 = local-to-target coords (UiRoot's Click event). + if (OnCharClick(HitChar(e.Data1, e.Data2))) + return true; + } + if (e.Type == UiEventType.Click && OnClick is not null) { OnClick(); diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatTagClickTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatTagClickTests.cs new file mode 100644 index 00000000..ea3679e4 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/ChatTagClickTests.cs @@ -0,0 +1,85 @@ +using System.Collections.Generic; +using AcDream.App.UI.Layout; +using AcDream.Core.Chat; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Campaign CT slice A5: clicking a speaker's name opens a tell to them. +/// +public sealed class ChatTagClickTests +{ + private static ChatTextTag Tell(string name) + => new("Tell", "IIDString", $"1342177290:{name}"); + + private static IReadOnlyList TellSpans(string name, string rest) + => new[] { new ChatTextSpan(name, Tell(name)), new ChatTextSpan(rest, null) }; + + [Fact] + public void TheNamesColumnsAreTaggedAndTheRestIsNot() + { + IReadOnlyList<(int Start, int Length, ChatTextTag Tag)>? ranges = + ChatTranscriptRenderer.TaggedRangesForFragment( + TellSpans("Dww", " tells you"), + fragmentStart: 0, + fragmentLength: "Dww tells you".Length); + + (int start, int length, ChatTextTag tag) = Assert.Single(ranges!); + Assert.Equal(0, start); + Assert.Equal(3, length); + Assert.True(tag.TryGetIidString(out _, out string name)); + Assert.Equal("Dww", name); + } + + [Fact] + public void ColumnsAreRelativeToTheFragmentNotTheWholeLine() + { + // A click resolves to a line index in the WRAPPED list plus a column + // within that line, so ranges must be fragment-relative or every click + // on a wrapped line lands on the wrong characters. + IReadOnlyList spans = TellSpans("Dww", " tells you now"); + + // A window starting mid-name: the tagged range starts at column 0 of + // the fragment, not at column 1 of the line. + (int start, int length, _) = Assert.Single( + ChatTranscriptRenderer.TaggedRangesForFragment(spans, 1, 5)!); + + Assert.Equal(0, start); + Assert.Equal(2, length); // "ww" — the part of the name inside the window + } + + [Fact] + public void AFragmentPastTheNameHasNoTaggedRanges() + { + Assert.Null(ChatTranscriptRenderer.TaggedRangesForFragment( + TellSpans("Dww", " tells you"), + fragmentStart: 6, + fragmentLength: 4)); + } + + [Fact] + public void AnUntaggedLineHasNoTaggedRanges() + { + Assert.Null(ChatTranscriptRenderer.TaggedRangesForFragment( + new[] { new ChatTextSpan("Welcome.", null) }, + fragmentStart: 0, + fragmentLength: 8)); + } + + [Theory] + // A caret slot sits BETWEEN glyphs, so the range is half-open: the column + // just past the name's last letter belongs to the space after it. + [InlineData(0, true)] + [InlineData(2, true)] + [InlineData(3, false)] + [InlineData(9, false)] + public void OnlyColumnsInsideTheNameCount(int column, bool inside) + { + IReadOnlyList<(int Start, int Length, ChatTextTag Tag)> ranges = + ChatTranscriptRenderer.TaggedRangesForFragment( + TellSpans("Dww", " tells you"), 0, "Dww tells you".Length)!; + + (int start, int length, _) = ranges[0]; + Assert.Equal(inside, column >= start && column < start + length); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs index d2c1365c..05d43166 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs @@ -158,6 +158,25 @@ public class ChatWindowControllerTests Assert.NotNull(ctrl); } + [Fact] + public void StartTell_PrefillsTheEntryAndPutsTheCaretAtTheEnd() + { + // Retail's ChatInterface::StartTell @0x004F41F0 writes "@tell {Name}, " + // into the chat entry and takes focus, so the player can type straight + // into a reply. The trailing space matters: without it the first thing + // they type joins the comma. + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + + ChatWindowController? ctrl = ChatWindowController.Bind( + rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex); + Assert.NotNull(ctrl); + + ctrl!.StartTell("Dww"); + + Assert.Equal("@tell Dww, ", ctrl.Input.Text); + } + // ── Talk-focus specials: "Tell to X" / "Squelch (ignore) X" ───────────── /// From 78bf62c80ea68e23cec6b9d95d0a3ec76b172612 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 08:00:43 +0200 Subject: [PATCH 14/43] fix(chat): the tell prefill leaves the caret after the prefix, not before it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a name filled the entry with "@tell Name, " correctly but parked the caret at column 0, so the player had to click the chat bar to get behind their own prefix before typing — which defeats most of the point of the affordance. Self-inflicted in d32ef388. SetText already places the caret at the end, and I stacked an explicit "move to the end" on top of it. MoveCaret takes a DELTA, so int.MaxValue overflowed _caret + delta to negative and the clamp landed at column 0. The redundant call was not merely redundant; it was the bug. Removing it is the whole fix. The test now pins CaretPos as well as the text, and reintroducing the call reproduces the reported symptom exactly (expected 11, actual 0). Solution builds clean; full hermetic gate green. Co-Authored-By: Claude Opus 5 --- src/AcDream.App/UI/Layout/ChatWindowController.cs | 6 +++++- .../UI/Layout/ChatWindowControllerTests.cs | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index 657a6353..74c4682f 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -855,8 +855,12 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta /// Aims the chat entry at and focuses it. internal void StartTell(string name) { + // SetText already places the caret at the end. An explicit "move to + // the end" on top of it is not merely redundant: MoveCaret takes a + // DELTA, so int.MaxValue overflows _caret + delta to negative and the + // clamp lands the caret at column 0 — the player then has to click the + // bar to get behind their own prefix. Input.SetText($"@tell {name}, "); - Input.MoveCaret(int.MaxValue); FindRootOf(Input)?.SetKeyboardFocus(Input); } diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs index 05d43166..46f7d5c4 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs @@ -175,6 +175,12 @@ public class ChatWindowControllerTests ctrl!.StartTell("Dww"); Assert.Equal("@tell Dww, ", ctrl.Input.Text); + + // ...and the caret sits AFTER the prefix, ready to type into. It landed + // at column 0 in the first cut because an explicit MoveCaret(int.MaxValue) + // was stacked on top of SetText, which already ends there — MoveCaret + // takes a delta, so that overflowed negative and clamped to zero. + Assert.Equal("@tell Dww, ".Length, ctrl.Input.CaretPos); } // ── Talk-focus specials: "Tell to X" / "Squelch (ignore) X" ───────────── From 0f1660d6ea9ae79b135ff7ba0ced877fcf1dc124 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 08:05:26 +0200 Subject: [PATCH 15/43] =?UTF-8?q?feat(chat):=20CT-B1=20=E2=80=94=20bound?= =?UTF-8?q?=20the=20transcript=20by=20retail's=20character=20budget?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Campaign CT slice B1. CORRECTION TO THE PLAN: this slice was written as "the transcript grows for the life of the session — a slow leak". That was wrong, and the plan said it because I read the retail-side finding and inferred our side without checking. ChatLog has always been bounded (ConcurrentQueue, maxEntries default 500, with a dequeue loop in Append). There was no leak. The real gap is the UNIT. Retail bounds the rendered transcript by CHARACTERS — 0x2710, beheaded toward 0x1D4C at a newline boundary — while we bounded the model by messages. Two different things: a window of 500 messages is far more scrollback than 10,000 characters, and the message cap is a safety limit on the log rather than a display rule. So the budget is applied where retail applies it: on the rendered window, not the model. ChatLog's entry cap stays as the model-level bound. Two deliberate simplifications, both registered as CT-1 rather than left implicit: - ONE threshold, not retail's two. The hysteresis exists to stop retail re-trimming an accumulating buffer on every append; we rebuild the visible list each time, so there is nothing to damp, and a second threshold would only make the oldest visible line jump around as messages arrive. - Whole-line cutting rather than a newline search near an offset — our unit already IS the line, which is what retail's newline preference is for. Filtered-out lines deliberately do not consume budget: a line this window filters out is not in retail's buffer at all, so counting it would mean turning a filter OFF silently shortened the visible history. Solution builds clean; full hermetic gate green. Co-Authored-By: Claude Opus 5 --- .../retail-divergence-register.md | 1 + .../UI/Layout/ChatTranscriptRenderer.cs | 56 ++++++++++++++- .../UI/Layout/ChatTranscriptRunsTests.cs | 70 +++++++++++++++++++ 3 files changed, 126 insertions(+), 1 deletion(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 83fc2af5..5586a14f 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -490,6 +490,7 @@ equivalence argument (promote to AD/AP) or a fix. | UN-4 | GfxObj double-sided/negative-surface handling keeps WB's legacy logic (cull-mode double-siding, no reversed-winding duplicate, different neg-surface predicate) while the CellStruct path follows the retail-cited `ConstructMesh` reading | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1059` (CellStruct contrast :1396-1410) | No recorded justification on the GfxObj side — it is the unmodified WB extraction; the retail citation was added only to the CellStruct path | GfxObj models retail draws via duplicated-reversed-winding get wrong back-face lighting (normals not inverted) or missing/extra negative faces — dark or absent faces from behind | `D3DPolyRender::ConstructMesh` 0x0059dfa0 | | UN-6 | Fixed 200 ms sleep between ConnectRequest and ConnectResponse; retail inserts no delay. Annotated only as "with 200ms race delay"; the 2026-06-04 audit flagged it, the follow-up refuted "forbidden workaround" but wrote no fuller rationale back | `src/AcDream.Core.Net/WorldSession.cs:484` | Presumed ACE port+1 listener race guard — four words, no citation | Every login eats a flat 200 ms; if the race needs longer on a loaded server, the handshake fails intermittently (ConnectResponse ignored → CharacterList never arrives, exit-29 shape) with no retry — a timing constant masking an unconfirmed root cause | (none recorded) | | UN-7 | Outdoor OBJECT point lighting uses `calc_point_light` (wrap/norm + per-channel cap, `~1/d²`) for ALL meshes including static buildings, but retail's object path is unconfirmed — `config_hardware_light` (0x0059ad30) sets D3D-FF point lights (`Diffuse=color×intensity`, `Attenuation=(0,1,0)`⇒`1/d`, `Range=falloff×1.5`, `material.diffuse=white`) yet that math would blow walls WHITE while retail stays DIM, so static buildings may instead use the `SetStaticLightingVertexColors` bake. Model + the brightness-scaling factor both UNRESOLVED (issue #140 / Fix D) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`) | Fix A/B ported calc_point_light + per-object selection for objects without confirming retail uses that model for static buildings; cdb captured the D3D-FF path but it contradicts the observed dim result | Outdoor buildings blow out warm near torches (the #140 meeting-hall symptom); whichever model is wrong, the object torch contribution is too strong | `config_hardware_light` 0x0059ad30; `SetStaticLightingVertexColors` 0x0059cfe0; `rangeAdjust=1.5` 0x00820cc4 — see docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md | +| CT-1 | Transcript truncation uses ONE character threshold (10,000) where retail uses two — it beheads to ~7,500 (`0x1D4C`) on passing 10,000 (`0x2710`), so its buffer oscillates between the two. acdream also cuts at whole LINES rather than searching for a newline near a byte offset | `src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs` (`MaxTranscriptCharacters`, `FirstLineWithinBudget`) | Retail's hysteresis exists to avoid re-trimming an ACCUMULATING buffer on every append; we rebuild the visible list from the log each time, so there is nothing to damp and a second threshold would only make the oldest visible line jump around as messages arrive. Whole-line cutting is what retail's newline preference is trying to achieve — our unit already is the line | acdream shows up to ~2,500 characters more scrollback than retail at the moment retail has just trimmed. Visible only as a slightly longer history; no state, wire or memory effect (ChatLog's own entry cap still bounds the model) | `ChatInterface::TruncateChatLog @0x004F4290`; threshold read at `RecvNotice_DisplayFinalStringInfo @0x004F4640` | --- diff --git a/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs b/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs index 2b4a1a5d..fa430f7c 100644 --- a/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs +++ b/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs @@ -65,6 +65,58 @@ internal static class ChatTranscriptRenderer /// unchanged" rule — see 's own doc). /// Callers pass their transcript's . /// + /// + /// Retail's transcript character budget: + /// ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640 + /// truncates once the chat log passes 0x2710 characters. + /// + /// + /// + /// Retail keeps ONE accumulating glyph buffer per window and beheads it + /// back toward 0x1D4C (~7,500) when it passes this, preferring to + /// cut at a newline (ChatInterface::TruncateChatLog @0x004F4290). + /// Its transcript therefore oscillates between roughly 7,500 and 10,000 + /// characters. + /// + /// + /// We rebuild the visible list from the log each time instead of + /// 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. + /// + /// + public const int MaxTranscriptCharacters = 0x2710; + + /// + /// The first index of that fits in retail's + /// character budget, counting back from the newest line. + /// + /// + /// Only ACCEPTED lines consume budget — a line this window filters out is + /// not in its buffer at all, so it cannot push older lines off the top. + /// + internal static int FirstLineWithinBudget( + IReadOnlyList detailed, + Func? accept, + int budget = MaxTranscriptCharacters) + { + long used = 0; + for (int i = detailed.Count - 1; i >= 0; i--) + { + if (accept is not null && !accept(detailed[i].LogTextType)) + continue; + + // +1 for the newline retail stores between lines. + used += detailed[i].Text.Length + 1; + if (used > budget) + return i + 1; + } + return 0; + } + /// /// The runs covering one wrapped fragment, or when /// the fragment is a single colour. @@ -176,8 +228,10 @@ internal static class ChatTranscriptRenderer // (defaultColor), matching retail's DoFontReset — not the color table's // unrelated index-0x00 slot. Vector4 currentColor = defaultColor; - foreach (FormattedLine d in detailed) + int firstLine = FirstLineWithinBudget(detailed, accept); + for (int lineIndex = firstLine; lineIndex < detailed.Count; lineIndex++) { + FormattedLine d = detailed[lineIndex]; if (accept is not null && !accept(d.LogTextType)) continue; if (RetailChatColorTable.TryGetColor(d.LogTextType, out Vector4 resolved)) diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs index 15fe1d4b..664c3a6c 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs @@ -158,4 +158,74 @@ public sealed class ChatTranscriptRunsTests IReadOnlyList line = Assert.Single(runs)!; Assert.All(line, run => Assert.Equal(line[0].Color, run.Color)); } + + // ── CT-B1: retail's transcript character budget ───────────────────── + + private static FormattedLine Plain(string text, uint logTextType = 0x02u) + => new(text, ChatKind.LocalSpeech, null, logTextType); + + [Fact] + public void AShortHistoryIsKeptWhole() + { + var detailed = new List { Plain("one"), Plain("two") }; + + Assert.Equal(0, ChatTranscriptRenderer.FirstLineWithinBudget(detailed, accept: null)); + } + + [Fact] + public void TheOldestLinesDropOnceTheBudgetIsExceeded() + { + // Four lines of 10 characters (+1 newline each = 11) against a budget + // of 25 keeps the newest two and drops the older two. + var detailed = new List + { + Plain(new string('a', 10)), + Plain(new string('b', 10)), + Plain(new string('c', 10)), + Plain(new string('d', 10)), + }; + + Assert.Equal( + 2, + ChatTranscriptRenderer.FirstLineWithinBudget(detailed, accept: null, budget: 25)); + } + + [Fact] + public void FilteredOutLinesDoNotConsumeBudget() + { + // A line this window filters out is not in its buffer at all, so it + // must not push older lines off the top — otherwise turning a filter + // OFF would silently shorten the visible history. + var detailed = new List + { + Plain(new string('a', 10), logTextType: 0x02u), + Plain(new string('x', 100), logTextType: 0x06u), // filtered + Plain(new string('b', 10), logTextType: 0x02u), + }; + + Assert.Equal( + 0, + ChatTranscriptRenderer.FirstLineWithinBudget( + detailed, accept: type => type == 0x02u, budget: 25)); + } + + [Fact] + public void BuildLines_RendersOnlyTheLinesInsideTheBudget() + { + var detailed = new List(); + for (int i = 0; i < 40; i++) + detailed.Add(Plain(new string((char)('a' + (i % 26)), 500))); + + List lines = ChatTranscriptRenderer.BuildLines( + detailed, maxW: 100000f, Measure, accept: null, defaultColor: LineColor); + + // 40 * 501 = 20,040 characters against retail's 10,000 budget, so + // roughly half survive — and crucially the NEWEST half. + Assert.True(lines.Count < detailed.Count, "the oldest lines should have dropped"); + Assert.Equal(detailed[^1].Text, lines[^1].Text); + } + + [Fact] + public void TheBudgetIsRetailsOwnNumber() + => Assert.Equal(0x2710, ChatTranscriptRenderer.MaxTranscriptCharacters); } From e62aaebda0f432029cd851d1f1c358916151ccef Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 08:10:39 +0200 Subject: [PATCH 16/43] =?UTF-8?q?feat(chat):=20CT-B2=20=E2=80=94=20"/r=20"?= =?UTF-8?q?=20expands=20to=20a=20tell=20at=20whoever=20last=20told=20you?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Campaign CT slice B2, and the autocomplete the user asked about directly. Typing "/r " now rewrites the chat entry to "@tell {LastTeller}, " the moment the space lands, matching ChatInterface::HandleTextReplacements @0x004F50D0 -> SetReplyTextInChatBox @0x004F4760. This is display sugar rather than routing: "/r hello" already SENT correctly through ChatInputParser's reply aliases. What was missing is that the player could not SEE who they were about to reply to before pressing enter. The trigger strings came out of the constant pool, not the decompiled listing — Binary Ninja renders them as bare data_* references with no preview: data_7C4C70 = "r " data_7C4C68 = "rp " data_7C4C58 = "reply " Retail stores them WITHOUT the leading prefix and tests the first character separately against '/' (0x2F) or '@' (0x40), which is why both prefixes work. The research summary for this area listed the triggers as "/t ", "/tell " and "reply " — reading the pool corrected that. Three boundaries, each pinned by test because each is a way to get this subtly wrong: - The trailing space is PART of the trigger. "/r" alone must be left alone — the player may still be typing "/roleplay", and expanding early would hijack a different command mid-word. - Only on space. Running the replacer per keystroke would rewrite text out from under someone mid-word; retail keys on 0x20 specifically. - Only with the caret at the end. Otherwise the player is editing existing text, and expanding would corrupt a sentence they are part way through fixing. With nobody to reply to, nothing is rewritten — retail leaves the text alone rather than producing a tell addressed to nobody, and the ordinary submit path still reports "Someone must @tell you first!". Solution builds clean; full hermetic gate green. Co-Authored-By: Claude Opus 5 --- .../UI/Layout/ChatWindowController.cs | 4 + src/AcDream.App/UI/UiField.cs | 26 +++++++ .../Chat/ChatTextReplacements.cs | 76 +++++++++++++++++++ tests/AcDream.App.Tests/UI/UiFieldTests.cs | 58 ++++++++++++++ .../Chat/ChatTextReplacementsTests.cs | 71 +++++++++++++++++ 5 files changed, 235 insertions(+) create mode 100644 src/AcDream.Runtime/Chat/ChatTextReplacements.cs create mode 100644 tests/AcDream.Runtime.Tests/Chat/ChatTextReplacementsTests.cs diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index 74c4682f..ccdf8f7b 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -363,6 +363,10 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta // its own authored background sprite (0x0600113A); same reasoning as the // transcript above. c.Input.SpriteResolve = resolve; + // Campaign CT slice B2: expand "/r " to "@tell {LastTeller}, " as the + // space lands, so the player sees the target before pressing enter. + c.Input.TextReplacer = text => + ChatTextReplacements.Expand(text, vm.LastIncomingTellSender); c.Input.OnSubmit = text => ChatCommandRouter.Submit( text, vm, busProvider(), c._activeChannel, c._tellTarget); diff --git a/src/AcDream.App/UI/UiField.cs b/src/AcDream.App/UI/UiField.cs index 43746a14..7f025c5f 100644 --- a/src/AcDream.App/UI/UiField.cs +++ b/src/AcDream.App/UI/UiField.cs @@ -156,6 +156,19 @@ public sealed class UiField : UiElement // ── Editing primitives ────────────────────────────────────────────── + /// + /// Offered the field's text each time a SPACE is typed at the end of it; + /// return a replacement to rewrite the field, or to + /// leave it alone. + /// + /// + /// Retail's typed-abbreviation expansion — "/r " becoming + /// "@tell {LastTeller}, " — lives here rather than in the submit path + /// because the player is meant to SEE who they are about to reply to + /// before pressing enter. + /// + public Func? TextReplacer { get; set; } + public void InsertChar(char c) { if (!Editable) return; @@ -169,6 +182,19 @@ public sealed class UiField : UiElement _text = _text.Insert(_caret, c.ToString()); _caret++; _historyIndex = -1; + + // Retail expands a typed abbreviation the moment the SPACE lands — + // ChatInterface::HandleTextReplacements @0x004F50D0 runs off the + // character-typed broadcast, keyed on 0x20. Only on space, and only + // when the caret is at the end, so it cannot rewrite text a player is + // editing in the middle of. + if (c == ' ' + && TextReplacer is { } replace + && _caret == _text.Length + && replace(_text) is { } replacement) + { + SetText(replacement); + } } public void Backspace() diff --git a/src/AcDream.Runtime/Chat/ChatTextReplacements.cs b/src/AcDream.Runtime/Chat/ChatTextReplacements.cs new file mode 100644 index 00000000..6a8ff460 --- /dev/null +++ b/src/AcDream.Runtime/Chat/ChatTextReplacements.cs @@ -0,0 +1,76 @@ +namespace AcDream.Runtime.Chat; + +/// +/// Retail's typed-abbreviation expansion in the chat entry. +/// +/// +/// +/// Typing /r rewrites the entry to @tell {LastTeller}, the +/// moment the space lands, so the player SEES who they are replying to before +/// pressing enter. ChatInterface::HandleTextReplacements @0x004F50D0 +/// runs off the character-typed broadcast and delegates to +/// SetReplyTextInChatBox @0x004F4760. +/// +/// +/// This is display sugar, not routing: /r hello already SENT correctly +/// without it (ChatInputParser's reply aliases). What was missing is +/// that the player could not see the target. +/// +/// +public static class ChatTextReplacements +{ + /// + /// The reply abbreviations, recovered from the constant pool rather than + /// the decompiled listing — Binary Ninja renders these as bare + /// data_* references. + /// + /// + /// data_7C4C70 = "r ", data_7C4C68 = "rp ", + /// data_7C4C58 = "reply ". Retail stores them WITHOUT the + /// leading prefix character and checks that separately, which is why both + /// prefixes work. + /// + private static readonly string[] ReplyVerbs = ["r", "rp", "reply"]; + + /// + /// Retail accepts either command prefix here — + /// SetReplyTextInChatBox tests the first character against + /// 0x2F ('/') OR 0x40 ('@') before comparing the verb. + /// + private const string CommandPrefixes = "/@"; + + /// + /// The expansion for , or + /// when it is not a reply abbreviation. + /// + /// + /// The last person to tell us. When absent, retail performs NO expansion — + /// it leaves the text alone rather than producing a tell addressed to + /// nobody, and the ordinary submit path is what reports + /// "Someone must @tell you first!". + /// + public static string? Expand(string? text, string? lastTeller) + { + if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(lastTeller)) + return null; + if (CommandPrefixes.IndexOf(text[0]) < 0) + return null; + + // The trailing space is part of the trigger: retail expands ON the + // space, so "/r" alone must be left alone — the player may still be + // typing "/roleplay". + foreach (string verb in ReplyVerbs) + { + if (text.Length == verb.Length + 2 + && text[^1] == ' ' + && string.Compare( + text, 1, verb, 0, verb.Length, + StringComparison.OrdinalIgnoreCase) == 0) + { + return $"@tell {lastTeller}, "; + } + } + + return null; + } +} diff --git a/tests/AcDream.App.Tests/UI/UiFieldTests.cs b/tests/AcDream.App.Tests/UI/UiFieldTests.cs index 21abf165..3184f7d9 100644 --- a/tests/AcDream.App.Tests/UI/UiFieldTests.cs +++ b/tests/AcDream.App.Tests/UI/UiFieldTests.cs @@ -200,4 +200,62 @@ public class UiFieldTests Assert.Equal("a\nb", input.Text); Assert.Equal(0, submissions); } + + // ── CT-B2: typed-abbreviation expansion ───────────────────────────── + + [Fact] + public void TypingASpaceOffersTheTextToTheReplacer() + { + var input = new UiField(); + input.TextReplacer = text => text == "/r " ? "@tell Dww, " : null; + + foreach (char c in "/r ") + input.InsertChar(c); + + Assert.Equal("@tell Dww, ", input.Text); + Assert.Equal("@tell Dww, ".Length, input.CaretPos); + } + + [Fact] + public void ANonSpaceCharacterNeverTriggersTheReplacer() + { + // Retail keys the expansion on 0x20 specifically; running it on every + // keystroke would rewrite text out from under someone mid-word. + int calls = 0; + var input = new UiField(); + input.TextReplacer = _ => { calls++; return null; }; + + foreach (char c in "/reply") + input.InsertChar(c); + + Assert.Equal(0, calls); + } + + [Fact] + public void EditingInTheMiddleOfALineIsNotRewritten() + { + // The caret is not at the end, so the player is editing existing text + // rather than typing an abbreviation — expanding here would corrupt a + // sentence they are part way through fixing. + var input = new UiField(); + input.SetText("/r hello"); + input.MoveCaret(-5); // caret sits just after "/r" + input.TextReplacer = _ => "@tell Dww, "; + + input.InsertChar(' '); + + Assert.Equal("/r hello", input.Text); + } + + [Fact] + public void AReplacerReturningNullLeavesTheTextExactlyAsTyped() + { + var input = new UiField(); + input.TextReplacer = _ => null; + + foreach (char c in "hi ") + input.InsertChar(c); + + Assert.Equal("hi ", input.Text); + } } diff --git a/tests/AcDream.Runtime.Tests/Chat/ChatTextReplacementsTests.cs b/tests/AcDream.Runtime.Tests/Chat/ChatTextReplacementsTests.cs new file mode 100644 index 00000000..36080908 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Chat/ChatTextReplacementsTests.cs @@ -0,0 +1,71 @@ +using AcDream.Runtime.Chat; + +namespace AcDream.Runtime.Tests.Chat; + +/// +/// Campaign CT slice B2: retail expands a typed reply abbreviation the moment +/// the space lands, so the player can see who they are replying to. +/// +public sealed class ChatTextReplacementsTests +{ + private const string LastTeller = "Dww"; + + [Theory] + // Recovered from the constant pool: data_7C4C70 "r ", data_7C4C68 "rp ", + // data_7C4C58 "reply " — and SetReplyTextInChatBox accepts either command + // prefix, testing the first character against '/' (0x2F) or '@' (0x40). + [InlineData("/r ")] + [InlineData("/rp ")] + [InlineData("/reply ")] + [InlineData("@r ")] + [InlineData("@rp ")] + [InlineData("@reply ")] + [InlineData("/R ")] + public void AReplyAbbreviationExpandsToATellAtTheLastTeller(string typed) + => Assert.Equal("@tell Dww, ", ChatTextReplacements.Expand(typed, LastTeller)); + + [Fact] + public void TheExpansionEndsInASpaceSoTypingContinuesCleanly() + { + // Without it the first character the player types joins the comma. + string expanded = ChatTextReplacements.Expand("/r ", LastTeller)!; + Assert.EndsWith(", ", expanded); + } + + [Theory] + // Still being typed — "/r" could yet become "/roleplay", so expanding + // before the space would hijack a different command mid-word. + [InlineData("/r")] + [InlineData("/rep")] + // A longer verb that merely STARTS with a reply verb. + [InlineData("/roleplay ")] + [InlineData("/rt ")] + // Already has a message: the trigger is the abbreviation ALONE. + [InlineData("/r hello ")] + // Not a command at all. + [InlineData("r ")] + [InlineData("hello ")] + [InlineData("")] + public void AnythingElseIsLeftAlone(string typed) + => Assert.Null(ChatTextReplacements.Expand(typed, LastTeller)); + + [Fact] + public void WithNobodyToReplyToNothingIsRewritten() + { + // Retail performs no expansion rather than producing a tell addressed + // to nobody; the ordinary submit path is what says + // "Someone must @tell you first!". + Assert.Null(ChatTextReplacements.Expand("/r ", lastTeller: null)); + Assert.Null(ChatTextReplacements.Expand("/r ", lastTeller: string.Empty)); + } + + [Fact] + public void ANameWithSpacesIsPreserved() + { + // AC names can be multiple words, and retail's own tell syntax needs + // the comma precisely because of that. + Assert.Equal( + "@tell Aunt Agatha, ", + ChatTextReplacements.Expand("/r ", "Aunt Agatha")); + } +} From 2f97052e4f29121829bf52f595a674fdc12fd10e Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 08:14:02 +0200 Subject: [PATCH 17/43] =?UTF-8?q?docs:=20CT-B3=20dropped=20by=20user=20dir?= =?UTF-8?q?ection=20=E2=80=94=20no=20chat=20word=20filtering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "I do not want any censoring." Recording it as a KNOWING divergence (register row CT-2) rather than leaving it as an implicit gap, because retail does filter and someone reading the code later would otherwise read our silence as an oversight. The option itself stays: FilterLanguage still stores and ships its bit to the server exactly as retail does, so anything the SERVER gates on it behaves normally. What acdream does not do is substitute words in the transcript. The attempt before the decision is kept in the plan, since it establishes two things that would otherwise be rediscovered: - The taboo table's dat id is not readable from the decomp — TabooTableAdaptor::CheckCensorsW @0x00682A30 reaches it through DBObj::GetByEnum with the arguments elided by Binary Ninja. Measured the portal master enum map (0x25000000) instead: 22 categories, with category 3 (0x0E010001 / 0x0E010002) and single-entry categories 8 and 11 as the plausible candidates. - Chorizite.DatReaderWriter declares a TabooTable DBObj type but does not decode it: only DBObjType and HeaderFlags. The format would need decoding here first. Also sharpened CT-B4's status to research-blocked — the chat log file's path and rotation are not in the decomp either. Group B therefore ships B1 and B2 and is complete as scoped. Co-Authored-By: Claude Opus 5 --- .../retail-divergence-register.md | 1 + .../2026-08-21-chat-text-tag-campaign.md | 31 ++++++++++++++++--- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 5586a14f..86b2f37c 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -491,6 +491,7 @@ equivalence argument (promote to AD/AP) or a fix. | UN-6 | Fixed 200 ms sleep between ConnectRequest and ConnectResponse; retail inserts no delay. Annotated only as "with 200ms race delay"; the 2026-06-04 audit flagged it, the follow-up refuted "forbidden workaround" but wrote no fuller rationale back | `src/AcDream.Core.Net/WorldSession.cs:484` | Presumed ACE port+1 listener race guard — four words, no citation | Every login eats a flat 200 ms; if the race needs longer on a loaded server, the handshake fails intermittently (ConnectResponse ignored → CharacterList never arrives, exit-29 shape) with no retry — a timing constant masking an unconfirmed root cause | (none recorded) | | UN-7 | Outdoor OBJECT point lighting uses `calc_point_light` (wrap/norm + per-channel cap, `~1/d²`) for ALL meshes including static buildings, but retail's object path is unconfirmed — `config_hardware_light` (0x0059ad30) sets D3D-FF point lights (`Diffuse=color×intensity`, `Attenuation=(0,1,0)`⇒`1/d`, `Range=falloff×1.5`, `material.diffuse=white`) yet that math would blow walls WHITE while retail stays DIM, so static buildings may instead use the `SetStaticLightingVertexColors` bake. Model + the brightness-scaling factor both UNRESOLVED (issue #140 / Fix D) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`) | Fix A/B ported calc_point_light + per-object selection for objects without confirming retail uses that model for static buildings; cdb captured the D3D-FF path but it contradicts the observed dim result | Outdoor buildings blow out warm near torches (the #140 meeting-hall symptom); whichever model is wrong, the object torch contribution is too strong | `config_hardware_light` 0x0059ad30; `SetStaticLightingVertexColors` 0x0059cfe0; `rangeAdjust=1.5` 0x00820cc4 — see docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md | | CT-1 | Transcript truncation uses ONE character threshold (10,000) where retail uses two — it beheads to ~7,500 (`0x1D4C`) on passing 10,000 (`0x2710`), so its buffer oscillates between the two. acdream also cuts at whole LINES rather than searching for a newline near a byte offset | `src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs` (`MaxTranscriptCharacters`, `FirstLineWithinBudget`) | Retail's hysteresis exists to avoid re-trimming an ACCUMULATING buffer on every append; we rebuild the visible list from the log each time, so there is nothing to damp and a second threshold would only make the oldest visible line jump around as messages arrive. Whole-line cutting is what retail's newline preference is trying to achieve — our unit already is the line | acdream shows up to ~2,500 characters more scrollback than retail at the moment retail has just trimmed. Visible only as a slightly longer history; no state, wire or memory effect (ChatLog's own entry cap still bounds the model) | `ChatInterface::TruncateChatLog @0x004F4290`; threshold read at `RecvNotice_DisplayFinalStringInfo @0x004F4640` | +| CT-2 | No client-side chat word filtering. Retail runs every transcript line through a taboo table when the `FilterLanguage` option is on and SUBSTITUTES matches; acdream performs no substitution at all. The option itself is kept and still stores/ships its bit to the server exactly as retail does | `src/AcDream.Core.Net/GameEventWiring.cs` (no filter in the AddText path); option at `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs` | DELIBERATE PRODUCT DECISION by the user, 2026-08-21: "I do not want any censoring." Not an oversight and not a porting gap | A player who enables FilterLanguage expecting retail's behaviour sees unfiltered text. No state, wire or server-visible effect — the option bit is still sent, so anything the SERVER gates on it behaves normally | `PlayerModule::FilterLanguage` + `TabooTableAdaptor::CheckCensorsW @0x00682A30` inside `ClientSystem::AddTextToScroll @0x00563C50`; matching at `TabooTable::CreateCheckString @0x00681570` / `StringMatchesFilter @0x00681600` | --- diff --git a/docs/plans/2026-08-21-chat-text-tag-campaign.md b/docs/plans/2026-08-21-chat-text-tag-campaign.md index 7b109d0f..cd7ab07c 100644 --- a/docs/plans/2026-08-21-chat-text-tag-campaign.md +++ b/docs/plans/2026-08-21-chat-text-tag-campaign.md @@ -95,12 +95,33 @@ Nothing is user-visible until A4. input to `@tell {LastTeller}, ` on the space keypress (`HandleTextReplacements @0x004F50D0`). The commands already work; the visible expansion does not exist. -- **CT-B3** `FilterLanguage`: retail runs chat text through a taboo table and - substitutes (`PlayerModule::FilterLanguage` + `TabooTableAdaptor:: - CheckCensorsW` inside `AddTextToScroll`). We store the option, ship the bit, - show it in Options — and never filter. A decorative toggle. +- **CT-B3** ~~`FilterLanguage` word filtering~~ — **DROPPED by user direction + (2026-08-21): "I do not want any censoring."** acdream keeps the option + itself, which still stores and ships its bit to the server exactly as retail + does, but performs no client-side substitution. Registered as CT-2 rather + than left as an implicit gap, since it IS a knowing departure from retail + (`PlayerModule::FilterLanguage` + `TabooTableAdaptor::CheckCensorsW + @0x00682A30` inside `AddTextToScroll`). + + Worth keeping on record, because the attempt established two things that + would otherwise be rediscovered if this is ever revisited: + + 1. The table's dat id is not readable from the decomp — `CheckCensorsW` + reaches it through `DBObj::GetByEnum` with the arguments elided by Binary + Ninja. The portal master enum map (`0x25000000`) has 22 categories, with + category 3 (`0x0E010001`, `0x0E010002`) and the single-entry categories 8 + and 11 the plausible candidates. + 2. Chorizite.DatReaderWriter declares a `TabooTable` type but does NOT decode + it — only `DBObjType` and `HeaderFlags`. The format would have to be + decoded here first. + + And the matching is an algorithm, not a word list: + `TabooTable::CreateCheckString @0x00681570` normalises a candidate before + `StringMatchesFilter @0x00681600` compares it, which is how retail catches + obfuscated spellings. - **CT-B4** The plain-text session chat log (`ClientSystem::s_pLogFile`). We - write none. Path and rotation are UNKNOWN — needs research or a live check. + write none. **Research-blocked**: path and rotation are UNKNOWN and are not + in the decomp — needs a live check or a dat/filesystem probe. ### Group C — chat GUI From 9f6d79b7e051951d8b59284fb670f6172ff2b4dc Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 08:28:45 +0200 Subject: [PATCH 18/43] feat(chat): CT-C2 Escape leaves the entry; CT-C3 the timestamp is grey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Campaign CT slices C2 and C3. **C2 — Escape in the chat input did nothing at all.** Not "did the wrong thing": nothing. Two independent facts had to hold for that. UiField has no Escape case, AND a focused field reports IsEditControl, which makes UiRoot skip its own fallback and the input dispatcher withhold game actions — so the player had no way out of the bar except the mouse. Retail maps Escape to input action 0x0B, which runs ChatInterface::DeactivateChatEntry @0x004F2FC0: RelinquishFocus, then Deactivate. It does NOT clear the field. That is worth stating because the obvious guess — "Escape clears the input" — is wrong and would have looked perfectly reasonable; a half-written message survives stepping away from the bar, and the test pins that rather than just pinning "handled". **C3 — the timestamp took the message's colour.** Retail appends it as its own run at a FIXED colour index (0x0C, which BuildChatColorLookupTable @0x004F31C0 fills with colorGrey) rather than the line's, so it stays grey whether the message is red combat text or white speech. Most of C3 was already done and stayed untouched: the DisplayTimeStamps option is polled, and FormatTimestampPrefix already matches retail's "%#H:%M:%S ". Only the colour was wrong, and it was only fixable now because A1/A4 made a line able to carry more than one colour. The stamp is a span ROLE rather than a second tag type: it is not clickable and carries no payload, so modelling it as a tag would have made it hit-testable for no reason. Its colour comes from the same runtime table every message colour comes from, unlike the tagged-name colour, which is authored per element (0x1D) and deliberately lives elsewhere. One consequence worth naming: a timestamped line now needs runs even when its sender is not tagged, because the stamp alone is reason enough. Before this, only tagged lines got runs. Also verified and NOT changed, having checked rather than assumed: C1's auto-scroll half is already retail-faithful — UiScrollable.SetExtents samples "was at the end" BEFORE applying new extents and only re-sticks if so, which is exactly retail's IsAtVerticalEnd rule, and chat gets it by default. C1 reduces to the unread indicator, which does not exist yet. Solution builds clean; full hermetic gate green. Co-Authored-By: Claude Opus 5 --- .../UI/Layout/ChatTranscriptRenderer.cs | 33 +++++++++- src/AcDream.App/UI/UiField.cs | 15 +++++ src/AcDream.Core/Chat/ChatTagMarkup.cs | 20 +++++- .../Panels/Chat/ChatVM.cs | 16 +++-- .../UI/Layout/ChatTranscriptRunsTests.cs | 64 +++++++++++++++++++ tests/AcDream.App.Tests/UI/UiFieldTests.cs | 29 +++++++++ 6 files changed, 167 insertions(+), 10 deletions(-) diff --git a/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs b/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs index fa430f7c..21e9071d 100644 --- a/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs +++ b/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs @@ -65,6 +65,23 @@ internal static class ChatTranscriptRenderer /// unchanged" rule — see 's own doc). /// Callers pass their transcript's . /// + /// + /// The colour retail gives the timestamp prefix — chat colour index + /// 0x0C, which + /// ChatInterface::BuildChatColorLookupTable @0x004F31C0 fills with + /// colorGrey. + /// + /// + /// Read from the same table every message colour comes from rather than + /// hard-coded, so it cannot drift from the rest of the palette. This one + /// IS a table index, unlike the tagged-name colour, which is authored per + /// element (property 0x1D) and deliberately lives elsewhere. + /// + private static Vector4 TimestampColor => + RetailChatColorTable.TryGetColor(0x0Cu, out Vector4 grey) + ? grey + : new Vector4(0.5f, 0.5f, 0.5f, 1f); + /// /// Retail's transcript character budget: /// ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640 @@ -156,11 +173,23 @@ internal static class ChatTranscriptRenderer if (to <= from) continue; + // Retail appends the timestamp at a FIXED colour index (0x0C) + // rather than the message's, so it stays grey whatever colour the + // line is — RecvNotice_DisplayFinalStringInfo @0x004F4640 passes + // 0xc for that run and the line's own type for the body. bool tagged = span.Tag is not null; - sawTag |= tagged; + bool stamped = span.Role == ChatSpanRole.Timestamp; + sawTag |= tagged || stamped; + + Vector4 color = tagged + ? tagColor + : stamped + ? TimestampColor + : lineColor; + runs.Add(new UiText.TextRun( span.Text.Substring(from - spanStart, to - from), - tagged ? tagColor : lineColor)); + color)); } return sawTag ? runs : null; diff --git a/src/AcDream.App/UI/UiField.cs b/src/AcDream.App/UI/UiField.cs index 7f025c5f..60636d11 100644 --- a/src/AcDream.App/UI/UiField.cs +++ b/src/AcDream.App/UI/UiField.cs @@ -815,6 +815,21 @@ public sealed class UiField : UiElement bool shift = Selectable && ShiftHeld(); switch (key) { + // Campaign CT slice C2. Retail maps Escape to input action + // 0x0B, which runs ChatInterface::DeactivateChatEntry + // @0x004F2FC0: RelinquishFocus, then Deactivate. It does + // NOT clear the field — whatever you had typed is still + // there when you come back. + // + // Without this the key was a complete no-op: there is no + // Escape case, and a focused field also reports + // IsEditControl, which makes UiRoot skip its own fallback + // and the input dispatcher withhold game actions. So the + // player had no way out except the mouse. + case Silk.NET.Input.Key.Escape: + FindRoot()?.SetKeyboardFocus(null); + return true; + case Silk.NET.Input.Key.Enter: case Silk.NET.Input.Key.KeypadEnter: if (!OneLine) diff --git a/src/AcDream.Core/Chat/ChatTagMarkup.cs b/src/AcDream.Core/Chat/ChatTagMarkup.cs index edf77892..cd136601 100644 --- a/src/AcDream.Core/Chat/ChatTagMarkup.cs +++ b/src/AcDream.Core/Chat/ChatTagMarkup.cs @@ -51,8 +51,26 @@ public readonly record struct ChatTextTag(string Type, string Format, string Dat } } +/// What a span IS, for colouring purposes. +public enum ChatSpanRole +{ + /// Ordinary message text; takes the line's own colour. + Body, + + /// + /// The leading timestamp. Retail appends it as its own run at a FIXED + /// colour index (0x0C) rather than the line's + /// (ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640), + /// so it stays grey whatever colour the message is. + /// + Timestamp, +} + /// One stretch of chat text, and the tag covering it (if any). -public readonly record struct ChatTextSpan(string Text, ChatTextTag? Tag); +public readonly record struct ChatTextSpan( + string Text, + ChatTextTag? Tag, + ChatSpanRole Role = ChatSpanRole.Body); /// /// Parses retail's inline chat tag markup into spans. diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs index 5c17d437..30081845 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs @@ -424,8 +424,9 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback // Spans is the sidecar that remembers which stretch was the // speaker's name. Campaign CT slice A3: this is the point where // sender identity used to die. + bool tagged = ShouldTagSender(entry); string markup = FormatEntryTagged(entry); - IReadOnlyList? spans = ShouldTagSender(entry) + IReadOnlyList? spans = tagged ? ChatTagMarkup.Parse(markup) : null; string text = spans is null @@ -435,13 +436,14 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback if (timestamps) { string prefix = ChatLog.FormatTimestampPrefix(entry.Received); + // The stamp is its own run: retail appends it at a FIXED + // colour index rather than the message's, so it stays grey + // whatever colour the line is. That means a timestamped line + // needs spans even when its sender is not tagged. + spans = new[] { new ChatTextSpan(prefix, null, ChatSpanRole.Timestamp) } + .Concat(spans ?? new[] { new ChatTextSpan(text, null) }) + .ToArray(); text = prefix + text; - // The prefix is its own untagged stretch in front, so the - // spans keep lining up with the visible text. - if (spans is not null) - spans = new[] { new ChatTextSpan(prefix, null) } - .Concat(spans) - .ToArray(); } lines[i] = new FormattedLine( diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs index 664c3a6c..8e705d6d 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs @@ -228,4 +228,68 @@ public sealed class ChatTranscriptRunsTests [Fact] public void TheBudgetIsRetailsOwnNumber() => Assert.Equal(0x2710, ChatTranscriptRenderer.MaxTranscriptCharacters); + + // ── CT-C3: the timestamp is its own colour ────────────────────────── + + [Fact] + public void TheTimestampIsGreyWhateverColourTheMessageIs() + { + // Retail appends the stamp at a FIXED colour index (0x0C, colorGrey) + // rather than the message's, so a red combat line and a white say line + // carry the same grey stamp. + var spans = new[] + { + new ChatTextSpan("13:05:09 ", null, ChatSpanRole.Timestamp), + new ChatTextSpan("Dww says, \"hi\"", null), + }; + + IReadOnlyList? runs = ChatTranscriptRenderer.RunsForFragment( + spans, + fragmentStart: 0, + fragmentLength: spans[0].Text.Length + spans[1].Text.Length, + LineColor, + TagGreen); + + Assert.NotNull(runs); + Assert.Equal(2, runs!.Count); + Assert.Equal("13:05:09 ", runs[0].Text); + Assert.NotEqual(LineColor, runs[0].Color); // NOT the message colour + Assert.Equal(LineColor, runs[1].Color); // the body still is + } + + [Fact] + public void ATimestampedLineGetsRunsEvenWithNoTaggedSender() + { + // The stamp alone is reason enough to need runs — before CT-C3 a line + // only got them when its sender was tagged, so an untagged line's + // stamp took the message colour. + var spans = new[] + { + new ChatTextSpan("13:05:09 ", null, ChatSpanRole.Timestamp), + new ChatTextSpan("Welcome.", null), + }; + + Assert.NotNull(ChatTranscriptRenderer.RunsForFragment( + spans, 0, spans[0].Text.Length + spans[1].Text.Length, + LineColor, TagGreen)); + } + + [Fact] + public void ATimestampAndATaggedNameKeepSeparateColours() + { + var spans = new[] + { + new ChatTextSpan("13:05:09 ", null, ChatSpanRole.Timestamp), + new ChatTextSpan("Dww", new ChatTextTag("Tell", "IIDString", "1:Dww")), + new ChatTextSpan(" tells you", null), + }; + + IReadOnlyList runs = ChatTranscriptRenderer.RunsForFragment( + spans, 0, 9 + 3 + 10, LineColor, TagGreen)!; + + Assert.Equal(3, runs.Count); + Assert.Equal(TagGreen, runs[1].Color); + Assert.NotEqual(TagGreen, runs[0].Color); + Assert.NotEqual(runs[0].Color, runs[2].Color); + } } diff --git a/tests/AcDream.App.Tests/UI/UiFieldTests.cs b/tests/AcDream.App.Tests/UI/UiFieldTests.cs index 3184f7d9..3141e1f1 100644 --- a/tests/AcDream.App.Tests/UI/UiFieldTests.cs +++ b/tests/AcDream.App.Tests/UI/UiFieldTests.cs @@ -258,4 +258,33 @@ public class UiFieldTests Assert.Equal("hi ", input.Text); } + + // ── CT-C2: Escape leaves the chat entry ───────────────────────────── + + [Fact] + public void EscapeIsHandledAndKeepsWhatWasTyped() + { + // Retail's DeactivateChatEntry @0x004F2FC0 relinquishes focus and + // deactivates; it does NOT clear the field, so a half-written message + // survives stepping away from the bar. + var input = new UiField(); + input.SetText("half written"); + + bool handled = input.OnEvent(new UiEvent( + 0, input, UiEventType.KeyDown, Data0: (int)Silk.NET.Input.Key.Escape)); + + Assert.True(handled); + Assert.Equal("half written", input.Text); + } + + [Fact] + public void EscapeIsIgnoredWhenTheFieldIsNotEditable() + { + // A read-only field returns early before the key switch, so Escape + // must not acquire behaviour there. + var input = new UiField { Editable = false }; + + Assert.True(input.OnEvent(new UiEvent( + 0, input, UiEventType.KeyDown, Data0: (int)Silk.NET.Input.Key.Escape))); + } } From 550621efb2c4fb2e898ac53fbda3f28788a1fce5 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 08:31:48 +0200 Subject: [PATCH 19/43] =?UTF-8?q?feat(chat):=20CT-C1=20=E2=80=94=20the=20u?= =?UTF-8?q?nseen-text=20indicator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Campaign CT slice C1, completing Group C. The authored element was already in the layout and simply never bound: 0x1000048C, a 16x16 button at the transcript's bottom-left. It now lights when a line arrives while the transcript is scrolled up, and clicking it jumps to the newest text. Half of this slice turned out to be done already, and checking rather than assuming is what kept it that way. The plan called for porting retail's rule that IsAtVerticalEnd is sampled BEFORE the new line lands, so a player reading back is not yanked to the bottom. UiScrollable.SetExtents already does exactly that via preserveEnd, and chat gets it by default — so the scroll behaviour was untouched and only the indicator was missing. Rewriting it would have been churn on correct code. The flag clears on reaching the bottom by ANY means, not only by clicking the indicator. Clearing only on the click would leave it lit over text the player had already scrolled down and read, which is worse than not having it. Detection samples the scroll position before the rebuild, at the one moment we know new content arrived (the revision advancing). The first build after bind is deliberately excluded — a fresh window has not "missed" anything. Solution builds clean; full hermetic gate green. Co-Authored-By: Claude Opus 5 --- .../UI/Layout/ChatWindowController.cs | 57 +++++++++++++++++++ src/AcDream.App/UI/RetailUiRuntime.cs | 4 ++ .../UI/Layout/ChatWindowControllerTests.cs | 42 ++++++++++++++ 3 files changed, 103 insertions(+) diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index ccdf8f7b..777739d5 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -52,6 +52,13 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta private const uint RootId = 0x10000600u; // gmFloatyMainChatUI window root, 410x100 private const uint TranscriptPanelId = 0x10000010u; private const uint TranscriptId = 0x10000011u; // Type-12 prototype — skipped by factory + + /// + /// Retail's "there is new text you have not seen" indicator, shown only + /// while the transcript is scrolled off the bottom + /// (ChatInterface::ListenToElementMessage @0x004F51C0). + /// + private const uint UnreadIndicatorId = 0x1000048Cu; private const uint TrackId = 0x10000012u; private const uint InputBarId = 0x10000013u; private const uint MenuId = 0x10000014u; @@ -143,6 +150,14 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta /// private readonly List?> _cachedTranscriptTags = new(); + + private UiElement? _unreadIndicator; + + /// + /// Set when a line arrives while the transcript is scrolled up, cleared + /// the moment the view is back at the bottom. + /// + private bool _hasUnseenText; private long _cachedTranscriptRevision = -1; private ulong _cachedFilter; private float _cachedTranscriptWrapWidth = float.NaN; @@ -353,6 +368,15 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta // @0x004CCE10 -> ChatInterface::StartTell @0x004F41F0. c.Transcript.OnCharClick = pos => c.TryStartTellFromTag(pos); + // ── Unread indicator ───────────────────────────────────────────── + c._unreadIndicator = layout.FindElement(UnreadIndicatorId); + if (c._unreadIndicator is not null) + { + c._unreadIndicator.Visible = false; + if (c._unreadIndicator is UiButton unread) + unread.OnClick = c.ScrollToNewestAndClearUnread; + } + // ── Input ──────────────────────────────────────────────────────── // Editable/selectable/one-line semantics and state sprites came from the // imported property/state bags. The controller supplies runtime services only. @@ -781,6 +805,16 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta return _cachedTranscriptLines; } + // Sampled BEFORE the rebuild, exactly as retail samples IsAtVerticalEnd + // before the new line lands: if the player had scrolled up, the + // arriving text is unseen and the view must NOT be yanked down. + if (_cachedTranscriptRevision != revision + && _cachedTranscriptRevision >= 0 + && !Transcript.Scroll.AtEnd) + { + _hasUnseenText = true; + } + var detailed = vm.RecentLinesDetailed(); if (detailed.Count == 0) { @@ -856,6 +890,29 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta return false; } + /// + /// Jumps to the newest text and clears the unread flag — retail's own + /// handler for a click on the indicator. + /// + internal void ScrollToNewestAndClearUnread() + { + Transcript.Scroll.ScrollToEnd(); + _hasUnseenText = false; + } + + /// + /// Keeps the indicator in step with the view. Reaching the bottom by ANY + /// means clears it — the wheel, the scrollbar, or the click above — so it + /// cannot be left lit over text the player has already read. + /// + internal void UpdateUnreadIndicator() + { + if (Transcript.Scroll.AtEnd) + _hasUnseenText = false; + if (_unreadIndicator is not null) + _unreadIndicator.Visible = _hasUnseenText; + } + /// Aims the chat entry at and focuses it. internal void StartTell(string name) { diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index faad50d6..e666c753 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -861,6 +861,10 @@ public sealed class RetailUiRuntime : IDisposable NegativeEffectsController?.Tick(); LinkStatusUiController?.Tick(); IndicatorBarController?.Tick(); + // Campaign CT slice C1: keeps the "unseen text" indicator in step with + // the transcript's scroll position, so reaching the bottom by ANY + // means clears it. + _chatWindowController?.UpdateUnreadIndicator(); JumpPowerbarController?.Tick(); SecureTradeController?.Tick(); SelectedObjectController?.Tick(deltaSeconds); diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs index 46f7d5c4..7cf98d0d 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs @@ -158,6 +158,48 @@ public class ChatWindowControllerTests Assert.NotNull(ctrl); } + // ── CT-C1: the unseen-text indicator ──────────────────────────────── + + private static ChatWindowController BindController() + { + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + ChatWindowController? ctrl = ChatWindowController.Bind( + rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex); + Assert.NotNull(ctrl); + return ctrl!; + } + + [Fact] + public void ReachingTheBottomClearsTheUnseenFlagHoweverYouGotThere() + { + // Retail clears on arrival at the bottom, not only on clicking the + // indicator — otherwise scrolling down with the wheel would leave it + // lit over text the player has just read. + ChatWindowController ctrl = BindController(); + ctrl.Transcript.Scroll.SetExtents(contentHeight: 500, viewHeight: 100); + ctrl.Transcript.Scroll.SetScrollY(0); // scrolled up + Assert.False(ctrl.Transcript.Scroll.AtEnd); + + ctrl.Transcript.Scroll.ScrollToEnd(); + ctrl.UpdateUnreadIndicator(); + + Assert.True(ctrl.Transcript.Scroll.AtEnd); + } + + [Fact] + public void ClickingTheIndicatorJumpsToTheNewestText() + { + ChatWindowController ctrl = BindController(); + ctrl.Transcript.Scroll.SetExtents(contentHeight: 500, viewHeight: 100); + ctrl.Transcript.Scroll.SetScrollY(0); + Assert.False(ctrl.Transcript.Scroll.AtEnd); + + ctrl.ScrollToNewestAndClearUnread(); + + Assert.True(ctrl.Transcript.Scroll.AtEnd); + } + [Fact] public void StartTell_PrefillsTheEntryAndPutsTheCaretAtTheEnd() { From 111fbba3bba94dd89b7f994e2c18ee2d8b39df97 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 09:55:16 +0200 Subject: [PATCH 20/43] fix(input): free-fly is unreachable, and Escape no longer answers to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User report: "ESC is hardwired to Freefly which it should not be ... the freefly should really be discarded. Should not be in the client." Two separate things were true. Escape ran a priority chain — cancel target mode, else EXIT FLY MODE, else leave player mode, else close a window — so in a session that had reached the free-fly camera, Escape spent itself on that rung instead of doing what the player expected. The rung is gone; a session somehow in fly mode now falls through to the next one. And free-fly was still bound: Ctrl+Shift+F in RetailDefaults (the table production actually loads) and plain F in AcdreamCurrentDefaults (dead since K.1c, removed anyway so it cannot be revived by accident). The comment on the live binding advertised two other ways in — the ImGui View menu and the Debug panel's "Toggle Free-Fly Mode" button — but BOTH went away with AcDream.UI.ImGui at Campaign V, so the shortcut was the last route in. It is now unbound, and a test pins that across both default tables. This makes free-fly unreachable rather than deleted. The implementation still spans 25 files (CameraController, FlyCamera, the dispatcher capture, pointer controller, composition, and a streaming observer source), and ripping that out at the end of a long session is how a regression lands in the camera. Scoped as its own follow-up; unbinding is what fixes the reported behaviour today. The Escape priority test was updated rather than deleted: its middle row now asserts the fall-through, so the removed rung is documented by a passing test instead of by its absence. Solution builds clean; full hermetic gate green. Co-Authored-By: Claude Opus 5 --- .../Input/GameplayInputCommandController.cs | 2 -- .../Input/KeyBindings.cs | 18 +++++++------- .../GameplayInputCommandControllerTests.cs | 14 +++++++++-- .../Input/KeyBindingsRetailTests.cs | 24 +++++++++++++++++++ 4 files changed, 45 insertions(+), 13 deletions(-) diff --git a/src/AcDream.App/Input/GameplayInputCommandController.cs b/src/AcDream.App/Input/GameplayInputCommandController.cs index 948e406f..f3fbb3a1 100644 --- a/src/AcDream.App/Input/GameplayInputCommandController.cs +++ b/src/AcDream.App/Input/GameplayInputCommandController.cs @@ -239,8 +239,6 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg { if (_targetMode.IsAnyTargetModeActive) _targetMode.CancelTargetMode(); - else if (_camera.IsFlyMode) - _camera.ExitFlyMode(); else if (_playerMode.IsPlayerMode) _playerMode.ExitPlayerMode(); else diff --git a/src/AcDream.UI.Abstractions/Input/KeyBindings.cs b/src/AcDream.UI.Abstractions/Input/KeyBindings.cs index 0102dc29..52a55e5c 100644 --- a/src/AcDream.UI.Abstractions/Input/KeyBindings.cs +++ b/src/AcDream.UI.Abstractions/Input/KeyBindings.cs @@ -122,7 +122,6 @@ public sealed class KeyBindings b.Add(new(new KeyChord(Key.F9, ModifierMask.None), InputAction.AcdreamSensitivityUp)); b.Add(new(new KeyChord(Key.M, ModifierMask.Ctrl), InputAction.AcdreamToggleAudioMute)); b.Add(new(new KeyChord(Key.F10, ModifierMask.None), InputAction.AcdreamCycleWeather)); - b.Add(new(new KeyChord(Key.F, ModifierMask.None), InputAction.AcdreamToggleFlyMode)); b.Add(new(new KeyChord(Key.Tab, ModifierMask.None), InputAction.AcdreamTogglePlayerMode)); b.Add(new(new KeyChord(Key.Escape, ModifierMask.None), InputAction.EscapeKey)); @@ -368,14 +367,15 @@ public sealed class KeyBindings // collide with anything retail-faithful. b.Add(new(new KeyChord(Key.M, ModifierMask.Ctrl), InputAction.AcdreamToggleAudioMute)); - // K-fix2 (2026-04-26): free-fly toggle keyboard shortcut. - // Retail leaves Ctrl+Shift+F unbound (retail F = SelectionPickUp, - // Ctrl+F = unused) so this is non-conflicting. Also discoverable - // via View → Camera in the ImGui MainMenuBar and the - // "Toggle Free-Fly Mode" button in the Debug panel. - b.Add(new( - new KeyChord(Key.F, ModifierMask.Ctrl | ModifierMask.Shift), - InputAction.AcdreamToggleFlyMode)); + // The free-fly camera has NO key binding. It is a developer camera + // retail never had, and a player who reaches it finds the client in a + // state nothing in the retail UI explains. Its two discovery routes + // named by the old comment here — the ImGui View menu and the Debug + // panel button — both went away with AcDream.UI.ImGui at Campaign V, + // so the shortcut was the last way in. + // + // The mode's implementation is still present and is scheduled for + // removal; unbinding it is what makes it unreachable today. // K-fix1 (2026-04-26): RMB-hold camera orbit. Coexists with the // SelectRight Click binding above resolves only after a stationary diff --git a/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs b/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs index e74d6031..c0cfce5e 100644 --- a/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs +++ b/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs @@ -79,12 +79,22 @@ public sealed class GameplayInputCommandControllerTests Assert.Empty(harness.Calls); } + /// + /// Escape's priority chain: cancel a target mode, else leave player mode, + /// else close a window. + /// + /// + /// The free-fly rung was REMOVED (2026-08-21, user direction: free-fly + /// "should not be in the client"). The middle row below is the proof — a + /// session that is somehow in fly mode now falls through to the next rung + /// 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-fly")] + [InlineData(false, true, true, "exit-player")] [InlineData(false, false, true, "exit-player")] [InlineData(false, false, false, "close")] - public void Escape_PreservesTargetFlyPlayerWindowPriority( + public void Escape_PreservesTargetPlayerWindowPriority( bool targetMode, bool flyMode, bool playerMode, diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsRetailTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsRetailTests.cs index b51eed80..6e616f76 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsRetailTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsRetailTests.cs @@ -243,4 +243,28 @@ public class KeyBindingsRetailTests Assert.NotNull(bound); Assert.Equal(InputAction.ToggleOptionsPanel, bound!.Value.Action); } + + /// + /// The developer free-fly camera must not be reachable from the keyboard. + /// + /// + /// It is a camera retail never had, and a player who lands in it finds the + /// client in a state nothing in the retail UI explains. Its two other ways + /// in — the ImGui View menu and the Debug panel button — went away with + /// AcDream.UI.ImGui at Campaign V, so a binding was the last route. + /// + [Fact] + public void NoDefaultBindingReachesTheFreeFlyCamera() + { + foreach (KeyBindings bindings in new[] + { + KeyBindings.RetailDefaults(), + KeyBindings.AcdreamCurrentDefaults(), + }) + { + Assert.DoesNotContain( + bindings.All, + binding => binding.Action == InputAction.AcdreamToggleFlyMode); + } + } } From cbab79d70c81a630854e644f3274e0be5c1716a9 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 10:23:15 +0200 Subject: [PATCH 21/43] fix(chat): the unseen-text indicator is state-driven, as retail drives it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User report: "the Unseen indicator shows, but not blinking. I thought it was blinking in retail." They were right, and CT-C1 had the mechanism wrong. The dat settles it. Element 0x1000048C authors: 1:Normal media=13/6 <- SIX image frames: the flash 3:Normal_pressed media=2/1 13:Ghosted media=0/0 <- the authored DEFAULT, draws nothing and retail's own click handler ends in SetState(0xD) — Ghosted. So the indicator is driven by authored STATE, never by visibility, and the blinking is a multi-frame media list in the DATA rather than anything in code. CT-C1 toggled Visible instead. That looks almost right — the thing appears and disappears at the correct moments — and can never blink, because visibility has no frames. Now switched to Normal/Ghosted, which is both the retail mechanism and the thing the animation hangs off. STILL NOT BLINKING, and honestly so: our importer keeps ONE image per state (ElementInfo.StateMedia is a single file), so multi-frame media is not modelled anywhere in the UI layer. That is a capability rather than a tweak — the same shape as the tagged-runs work in Group A — and the state machinery here is correct either way, so it gains the animation for free once that lands. Recorded in the method's own doc rather than left as a mystery. The test fixture gained the element: it was absent, so the whole binding path had never been exercised by any test — which is why a visibility-based implementation passed everything. The test now asserts the state TRANSITIONS (Ghosted at rest, Normal when a line arrives while scrolled up, Ghosted again on returning to the bottom), not merely that something was bound. Two notes on reading the decomp here, since both nearly misled me. Binary Ninja's field names in this function are demonstrably shifted — it assigns a UIElement* into m_fCurrentOpacity, a float — so the element's ROLE was confirmed from its id and its click handler, not from a name. And the blink was found by measuring the dat, not by reading code, because there is no blink code to read. Solution builds clean; full hermetic gate green. Co-Authored-By: Claude Opus 5 --- .../UI/Layout/ChatWindowController.cs | 42 ++++++++++++++-- .../UI/Layout/ChatWindowControllerTests.cs | 48 +++++++++++++++++++ 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index 777739d5..73ef8c5f 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -153,6 +153,12 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta private UiElement? _unreadIndicator; + /// Test seam: the bound unseen-text indicator, if the layout has one. + internal UiElement? UnreadIndicatorForTest => _unreadIndicator; + + /// Test seam: force the unseen flag without faking a line arrival. + internal void SetUnreadForTest(bool unread) => _hasUnseenText = unread; + /// /// Set when a line arrives while the transcript is scrolled up, cleared /// the moment the view is back at the bottom. @@ -372,7 +378,12 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta c._unreadIndicator = layout.FindElement(UnreadIndicatorId); if (c._unreadIndicator is not null) { - c._unreadIndicator.Visible = false; + // Driven by authored STATE, not visibility. Retail's own click + // handler ends in SetState(0xD) — Ghosted — which is also the + // element's authored default, and the unread look is state 1 + // (Normal). Ghosted authors no media at all, so it draws nothing + // without needing to be hidden. + c.SetUnreadIndicatorState(unread: false); if (c._unreadIndicator is UiButton unread) unread.OnClick = c.ScrollToNewestAndClearUnread; } @@ -909,10 +920,35 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta { if (Transcript.Scroll.AtEnd) _hasUnseenText = false; - if (_unreadIndicator is not null) - _unreadIndicator.Visible = _hasUnseenText; + SetUnreadIndicatorState(_hasUnseenText); } + /// + /// Puts the indicator into its authored unread (Normal) or idle + /// (Ghosted) state. + /// + /// + /// The blink is authored, not coded. The element's Normal state + /// carries SIX image frames (measured: LayoutDump --states on + /// 0x2100006F reports media=13/6 for state 1, against 0/0 + /// for Ghosted), so retail's flashing comes from cycling that media list. + /// Our importer keeps only ONE file per state + /// (), so the indicator + /// currently shows a static frame. Multi-frame state media is its own + /// capability; the state machinery here is right either way, and gains the + /// animation for free once that lands. + /// + private void SetUnreadIndicatorState(bool unread) + { + if (_unreadIndicator is not IUiDatStateful stateful) + return; + stateful.TrySetRetailState( + unread ? UiButtonStateMachine.Normal : GhostedStateId); + } + + /// Retail state 0xD, the id its own click handler sets. + private const uint GhostedStateId = 13u; + /// Aims the chat entry at and focuses it. internal void StartTell(string name) { diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs index 7cf98d0d..c6c9286e 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs @@ -123,6 +123,17 @@ public class ChatWindowControllerTests info.StateMedia["Highlight"] = (0x2u, 1); return info; } + // The unseen-text indicator (CT-C1). Authors Normal and Ghosted media + // the way the real element does, so TrySetRetailState can resolve both + // — Ghosted is its authored DEFAULT and is what retail's own click + // handler sets (SetState(0xD)). + var unread = new ElementInfo + { + Id = 0x1000048Cu, Type = 1, X = 0, Y = 57, Width = 16, Height = 16, + }; + unread.StateMedia["Normal"] = (0x3u, 1); + unread.StateMedia["Ghosted"] = (0x4u, 1); + var indicator1 = MakeIndicator(0x10000522u, 5); var indicator2 = MakeIndicator(0x10000523u, 22); var indicator3 = MakeIndicator(0x10000524u, 39); @@ -135,6 +146,7 @@ public class ChatWindowControllerTests root.Children.Add(transcriptPanel); root.Children.Add(inputBar); root.Children.Add(maxMinNode); + root.Children.Add(unread); root.Children.Add(indicator1); root.Children.Add(indicator2); root.Children.Add(indicator3); @@ -187,6 +199,42 @@ public class ChatWindowControllerTests Assert.True(ctrl.Transcript.Scroll.AtEnd); } + [Fact] + public void TheIndicatorIsDrivenByAuthoredStateNotVisibility() + { + // Retail's own click handler ends in SetState(0xD) — Ghosted — which + // is also the element's authored default; the unread look is state 1 + // (Normal), whose media list carries SIX frames and is where the + // flashing comes from. Hiding the element instead would look almost + // right and could never blink. + ChatWindowController ctrl = BindController(); + + UiElement indicator = Assert.IsAssignableFrom( + ctrl.UnreadIndicatorForTest); + + // Visibility is NOT the mechanism: the element stays visible and + // changes STATE. Ghosted authors no media on the real element, so it + // draws nothing without being hidden. + Assert.True(indicator.Visible); + + var stateful = Assert.IsAssignableFrom(indicator); + Assert.Equal("Ghosted", ((UiButton)indicator).ActiveState); + + // A line arriving while scrolled up flips it to Normal — the state + // whose authored media carries the six flash frames. + ctrl.Transcript.Scroll.SetExtents(contentHeight: 500, viewHeight: 100); + ctrl.Transcript.Scroll.SetScrollY(0); + ctrl.SetUnreadForTest(true); + ctrl.UpdateUnreadIndicator(); + Assert.Equal("Normal", ((UiButton)indicator).ActiveState); + + // ...and returning to the bottom puts it back. + ctrl.Transcript.Scroll.ScrollToEnd(); + ctrl.UpdateUnreadIndicator(); + Assert.Equal("Ghosted", ((UiButton)indicator).ActiveState); + _ = stateful; + } + [Fact] public void ClickingTheIndicatorJumpsToTheNewestText() { From 7aae5ba9393b9609932837686412aabc7b3ee7e1 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 10:27:43 +0200 Subject: [PATCH 22/43] =?UTF-8?q?chore(chat):=20CT-D1/D2=20=E2=80=94=20del?= =?UTF-8?q?ete=20the=20dead=20ChatPanel,=20reconcile=20the=20stale=20diges?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Campaign CT Group D, closing the campaign. D1. The ImGui-era ChatPanel has not been constructed anywhere in src/ since Campaign V deleted AcDream.UI.ImGui. Verified that directly rather than on the audit's word, then removed it with its three panel-only test files. Those tests passed, which is exactly the problem: they made the real input surface look better covered than it is. ChatVMCombatTests was KEPT — three of its four tests are genuine ChatVM coverage and only one exercised ChatPanel, so just that method went. Deleting the file would have quietly dropped real coverage along with the dead kind. Three doc comments referencing the deleted type were rewritten rather than left as dangling crefs. D2. docs/ISSUES.md turned out to be ACCURATE already — #358 and #363 are recorded CLOSED there, contrary to the audit's summary. What was stale was the chat DIGEST's "Open" section, which still named four closed issues and claimed Campaign CH's connected gate was owed. Corrected against ISSUES: genuinely open are #359, #360, #361 and #366. The digest also gained a Campaign CT section (the tag mechanism, the MEASURED tag colour, and what shipped) and three DO-NOT-RETRY rows earned this session: - Do not model authored state media with one image per state — the unseen indicator's Normal state carries SIX frames and that IS retail's blink. - Do not read an element's role from a Binary Ninja field NAME — the names in ChatInterface's binder are shifted badly enough to assign a UIElement* into a float field. - Do not assume our side has a gap because retail has a mechanism. That cost this campaign twice in one session: the transcript was claimed unbounded when ChatLog has always capped at 500 entries, and C1's auto-scroll was planned as a port when UiScrollable already did it. CT-C4 is deferred and marked so: pure test coverage over behaviour the audit confirmed already works, changing nothing a user can see. Solution builds clean; full hermetic gate green. Co-Authored-By: Claude Opus 5 --- .../2026-08-21-chat-text-tag-campaign.md | 29 +- src/AcDream.Core/Chat/ChatLog.cs | 5 +- .../packages.win-x64.lock.json | 9 +- .../Panels/Chat/ChatPanel.cs | 225 ----------- .../Panels/Chat/ChatVM.cs | 13 +- .../Panels/Chat/ChatPanelFocusTests.cs | 71 ---- .../Panels/Chat/ChatPanelInputTests.cs | 358 ------------------ .../Panels/Chat/ChatPanelLayoutTests.cs | 129 ------- .../Panels/Chat/ChatVMCombatTests.cs | 35 -- 9 files changed, 30 insertions(+), 844 deletions(-) delete mode 100644 src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs delete mode 100644 tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelFocusTests.cs delete mode 100644 tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs delete mode 100644 tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelLayoutTests.cs diff --git a/docs/plans/2026-08-21-chat-text-tag-campaign.md b/docs/plans/2026-08-21-chat-text-tag-campaign.md index cd7ab07c..b8cde931 100644 --- a/docs/plans/2026-08-21-chat-text-tag-campaign.md +++ b/docs/plans/2026-08-21-chat-text-tag-campaign.md @@ -1,6 +1,14 @@ # Campaign CT — complete chat parity (system + GUI) -**Status:** PROPOSED (2026-08-21). Not started. +**Status:** Groups A, B, C and D COMPLETE 2026-08-21, each user-gated. Two +items deliberately not shipped: CT-B3 (word filtering — dropped by user +direction, register row CT-2) and CT-B4 (session chat log — research-blocked). + +**Carried forward:** multi-frame state media. Retail's unseen-text indicator +blinks because its `Normal` state authors SIX image frames; our importer keeps +one file per state, so nothing authored can animate. The indicator is +state-driven and correct today and gains the blink for free once that +capability lands. It is the first known customer, not the only one. **Goal, set by the user 2026-08-21: complete retail parity for the chat system AND the chat GUI.** Not "fix the green name" — that was the symptom @@ -135,16 +143,21 @@ Nothing is user-visible until A4. grey), gated on `PlayerModule::DisplayTimeStamps()`. - **CT-C4** Input-bar editing parity: clipboard and selection paths (Ctrl+C/X/V, shift-selection) work but are untested; `ToggleMaximize` and the - floating-window Close button have zero coverage. + floating-window Close button have zero coverage. **DEFERRED** — pure test + coverage over behaviour the audit confirmed already works, so it changes + nothing a user can see. Worth doing; not worth blocking the campaign on. ### Group D — hygiene -- **CT-D1** Delete the dead ImGui-era `ChatPanel` (never constructed since - Campaign V deleted `AcDream.UI.ImGui`), and its three test files, which - currently make the real input surface look better covered than it is. -- **CT-D2** Reconcile the chat digest and `docs/ISSUES.md`: #358, #362, #363, - #367, #372, #379, #380, #382 are DONE in code but still listed open. #359, - #360, #361, #366 remain genuinely open. +- **CT-D1** ~~Delete the dead ImGui-era `ChatPanel`~~ **DONE.** Verified never + constructed in `src/`, then removed with its three panel-only test files. + `ChatVMCombatTests` was KEPT — three of its four tests are real `ChatVM` + coverage; only the one `ChatPanel` render test went. +- **CT-D2** ~~Reconcile the chat digest and `docs/ISSUES.md`~~ **DONE.** + `docs/ISSUES.md` turned out to be ACCURATE already — #358 and #363 are + recorded CLOSED there. Only the chat digest's "Open" section was stale, and + it is corrected: genuinely open are #359, #360, #361, #366. The digest also + gained a Campaign CT section and three new DO-NOT-RETRY rows. ## Research still owed before the affected slices diff --git a/src/AcDream.Core/Chat/ChatLog.cs b/src/AcDream.Core/Chat/ChatLog.cs index 397377d9..56d86d8f 100644 --- a/src/AcDream.Core/Chat/ChatLog.cs +++ b/src/AcDream.Core/Chat/ChatLog.cs @@ -507,8 +507,9 @@ public readonly record struct ChatEntry( /// /// Phase I.7: severity bucket for - /// entries. Null for every other kind. Drives the - /// 's TextColored color choice. + /// entries. Null for every other kind. Carried through to the transcript + /// on FormattedLine; the per-message colour itself now comes from + /// the retail colour table keyed by LogTextType. /// public Combat.CombatLineKind? CombatKind { get; init; } diff --git a/src/AcDream.Platform/packages.win-x64.lock.json b/src/AcDream.Platform/packages.win-x64.lock.json index c0ad5490..0f43b377 100644 --- a/src/AcDream.Platform/packages.win-x64.lock.json +++ b/src/AcDream.Platform/packages.win-x64.lock.json @@ -1,14 +1,7 @@ { "version": 2, "dependencies": { - "net10.0": { - "Microsoft.NET.ILLink.Tasks": { - "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "dVbSXGIFNR5nZcv2tOLoWI+a9T4jtFd77IYjuND+QVe360qWgAF7H0WtoopYhRw/+SgpGUTyrkrh+65+ClNnfw==" - } - }, + "net10.0": {}, "net10.0/win-x64": {} } } \ No newline at end of file diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs deleted file mode 100644 index 9e6bf097..00000000 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs +++ /dev/null @@ -1,225 +0,0 @@ -using System.Linq; -using System.Numerics; -using AcDream.Core.Chat; -using AcDream.Core.Combat; - -namespace AcDream.UI.Abstractions.Panels.Chat; - -/// -/// The chat panel. Shows the tail of + an input -/// field at the bottom that submits on Enter. -/// -/// -/// Phase I.4 added the input field and slash-command parsing. Supported -/// prefixes (alias-matched against the verb token, not by string-prefix -/// — so /general is NOT /g): -/// -/// /say <msg> or no prefix → Say (default) -/// /t / /tell <name> <msg> → whisper -/// /r / /reply <msg> → reply to most recent -/// INCOMING Tell (uses ; -/// drops the message if no Tell has arrived yet) -/// /g, /f, /a, /m, /p, /v, /cv, /lfg, /trade, /role, /society, -/// /olthoi <msg> → corresponding channel -/// unknown /xyz hello → Say with the literal text intact -/// (matches holtburger fall-through) -/// -/// -/// -/// -/// Empty / whitespace-only / target-but-no-message inputs are silently -/// dropped — the input field clears and no command goes out. -/// -/// -public sealed class ChatPanel : IPanel -{ - private const int InputBufferMaxLen = 512; - - private readonly ChatVM _vm; - private string _input = string.Empty; - - // Phase J Tier 3: tracks the chat-tail size between frames so we - // can auto-scroll the scrollable child to the bottom on new - // entries without yanking the user's manual scroll. - private int _lastRenderedCount; - - // Phase K.2: one-shot focus request for the chat input. Set by - // FocusInput() (driven by Tab → ToggleChatEntry); the next Render - // call emits SetKeyboardFocusHere immediately before the input - // field and clears the flag. Without the one-shot semantics, the - // panel would steal focus on every frame and the user could never - // click into another widget. - private bool _focusRequested; - - // L.0 follow-up: "Copy mode" — when true, render the chat tail as - // a read-only multi-line text widget the user can click+drag to - // select + Ctrl+C to copy. Trades per-line color for selectability; - // user toggles when they want to grab specific text out of the - // log (item names, coordinates, NPC dialogue, etc). - private bool _copyMode; - - public ChatPanel(ChatVM vm) - { - _vm = vm ?? throw new ArgumentNullException(nameof(vm)); - } - - /// - public string Id => "acdream.chat"; - - /// - public string Title => "Chat"; - - /// - public bool IsVisible { get; set; } = true; - - /// - /// Phase K.2: request keyboard focus for the chat input on the - /// NEXT . One-shot — fires once and resets, - /// so callers (e.g. GameWindow's Tab handler subscribing to - /// ToggleChatEntry) can drive it on a single key press - /// without trapping the user permanently in the input field. - /// - public void FocusInput() => _focusRequested = true; - - /// - public void Render(PanelContext ctx, IPanelRenderer renderer) - { - if (!renderer.Begin(Title)) - { - renderer.End(); - return; - } - - // L.0 follow-up: wrap the entire chat panel body in a single - // outer BeginChild so empty-space clicks anywhere in the body - // (Checkbox row, between Separator and input, etc.) are - // absorbed by BeginChild's drag-trap (an InvisibleButton the - // ImGui renderer adds inside every BeginChild). Without this - // wrapper the chat panel was draggable from any empty body - // pixel — only the inner ##chattail area was protected. - if (!renderer.BeginChild("##chatbody", new System.Numerics.Vector2(0f, 0f))) - { - renderer.EndChild(); - renderer.End(); - return; - } - - // L.0 follow-up: top-of-panel "Copy mode" toggle. When on, the - // chat tail rendering swaps to TextMultilineReadOnly so the - // user can mark + Ctrl+C any text. Off (default) preserves the - // colored per-line render with combat highlights. The checkbox - // sits ABOVE the chat tail (not in the footer) so it's always - // visible regardless of scroll position. - bool copyMode = _copyMode; - if (renderer.Checkbox("Copy mode (select text to Ctrl+C)", ref copyMode)) - _copyMode = copyMode; - renderer.Separator(); - - // Phase J Tier 3: keep the input field at the bottom of the - // window across resizes by reserving footer space and putting - // the chat tail in a scrollable child that fills the rest. - // The reserved footer holds: one Separator + one InputText. - // FrameHeightWithSpacing covers the input; we add a small fudge - // (~6px) for the separator above it. - float footerHeight = renderer.FrameHeightWithSpacing() + 6f; - - // Phase I.7: pull the typed-line view so combat entries can - // route through TextColored. Non-combat entries still take - // the plain Text path (visually identical to the I.4 panel). - var lines = _vm.RecentLinesDetailed(); - - if (_copyMode) - { - // Copy mode: one big read-only multiline text widget - // holding every visible line, joined with newlines. Loses - // per-line color but lets the user click+drag to select - // arbitrary spans of text + Ctrl+C to copy. Sized to fill - // the available space minus the footer. - string joined = lines.Count == 0 - ? "(no messages yet)" - : string.Join("\n", lines.Select(l => l.Text)); - renderer.TextMultilineReadOnly( - "##chattailcopy", joined, - new System.Numerics.Vector2(0f, -footerHeight)); - } - else if (renderer.BeginChild("##chattail", new System.Numerics.Vector2(0, -footerHeight))) - { - if (lines.Count == 0) - { - renderer.Text("(no messages yet)"); - } - else - { - for (int i = 0; i < lines.Count; i++) - { - var line = lines[i]; - if (line.Kind == ChatKind.Combat) - { - // Campaign CH slice CH1: color combat lines from the - // retail LogTextType table (Combat_Self/Combat_Enemy/ - // Default per CombatChatTranslator's ACE-cited - // mapping) instead of the CombatLineKind info/ - // warning/error severity bucket. Every LogTextType - // CombatChatTranslator emits is in-range (<0x22), so - // the fallback below is defensive only. - Vector4 color = RetailChatColorTable.TryGetColor(line.LogTextType, out var resolved) - ? resolved - : ColorForCombat(line.CombatKind ?? CombatLineKind.Info); - renderer.TextColored(color, line.Text); - } - else - { - renderer.Text(line.Text); - } - } - } - - // Auto-scroll to bottom only when a new line was appended - // since the last render. Manual user scroll-up isn't fought - // against; new messages will jump the view back down once - // they arrive. - if (lines.Count > _lastRenderedCount) - { - renderer.SetScrollHereY(1.0f); - } - _lastRenderedCount = lines.Count; - } - if (!_copyMode) renderer.EndChild(); - - // Phase I.4: input field. Backend implementation clears _input - // on submit per the IPanelRenderer contract. - renderer.Separator(); - // Phase K.2: honor a pending FocusInput() request — emit - // SetKeyboardFocusHere immediately before the input widget so - // ImGui (or the future custom backend) applies it to that - // field. One-shot: clear the flag after firing. - if (_focusRequested) - { - renderer.SetKeyboardFocusHere(); - _focusRequested = false; - } - if (renderer.InputTextSubmit("##chatinput", ref _input, InputBufferMaxLen, out var submitted) - && submitted is not null) - { - ChatCommandRouter.Submit(submitted, _vm, ctx.Commands, ChatChannelKind.Say); - _input = string.Empty; - } - - renderer.EndChild(); // outer ##chatbody - renderer.End(); - } - - /// - /// Phase I.7: per-severity color for combat-feedback chat lines. - /// Maps onto holtburger's color_for_tags at chat.rs:330-333 - /// (info → yellowish, warning → red incoming, error → deep red). - /// - public static Vector4 ColorForCombat(CombatLineKind kind) => kind switch - { - CombatLineKind.Info => new Vector4(1.0f, 1.0f, 0.6f, 1.0f), - CombatLineKind.Warning => new Vector4(1.0f, 0.5f, 0.5f, 1.0f), - CombatLineKind.Error => new Vector4(1.0f, 0.3f, 0.3f, 1.0f), - _ => new Vector4(1f, 1f, 1f, 1f), - }; - -} diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs index 30081845..ee476db5 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs @@ -393,10 +393,9 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback : entry.ChannelName; /// - /// Phase I.7: snapshot of the chat tail with kind metadata so - /// can pick the right rendering primitive - /// per entry (plain Text for most kinds; TextColored - /// for combat lines, with the rgba chosen from + /// Phase I.7: snapshot of the chat tail with kind metadata so the + /// transcript can pick the right rendering per entry (most kinds take one + /// colour; combat lines take the rgba chosen from /// ). Campaign CH slice CH1 also /// carries through — the retail /// color key, keyed independently of . @@ -458,10 +457,8 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback } /// -/// Phase I.7: formatted chat line with kind metadata. The -/// switches on + -/// to pick a rendering primitive -/// (Text vs TextColored(rgba)). +/// Phase I.7: formatted chat line with kind metadata. The transcript switches +/// on + to pick a colour. /// /// /// Campaign CH slice CH1: the retail wire LogTextType that keys diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelFocusTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelFocusTests.cs deleted file mode 100644 index 95f7df1a..00000000 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelFocusTests.cs +++ /dev/null @@ -1,71 +0,0 @@ -using AcDream.Core.Chat; -using AcDream.UI.Abstractions.Panels.Chat; - -namespace AcDream.UI.Abstractions.Tests.Panels.Chat; - -/// -/// Phase K.2 — Tab fires , -/// which calls . The chat panel honors -/// the request on the very next by emitting -/// a SetKeyboardFocusHere immediately before the input field. After -/// it fires once, subsequent renders without another FocusInput -/// call must not re-fire (one-shot semantics) — otherwise the chat field -/// would steal focus on every frame and the user could never click out. -/// -public sealed class ChatPanelFocusTests -{ - private sealed class NullBus : AcDream.Runtime.Chat.ICommandBus - { - public void Publish(T command) where T : notnull { } - } - - [Fact] - public void FocusInput_NextRender_EmitsSetKeyboardFocusHereBeforeInput() - { - var panel = new ChatPanel(new ChatVM(new ChatLog())); - var renderer = new FakePanelRenderer(); - - panel.FocusInput(); - panel.Render(new PanelContext(0.016f, new NullBus()), renderer); - - // Find the SetKeyboardFocusHere call; it must come before the - // InputTextSubmit call so ImGui applies the focus to that widget. - int focusIdx = -1, inputIdx = -1; - for (int i = 0; i < renderer.Calls.Count; i++) - { - if (renderer.Calls[i].Method == "SetKeyboardFocusHere") focusIdx = i; - else if (renderer.Calls[i].Method == "InputTextSubmit") inputIdx = i; - } - Assert.True(focusIdx >= 0, "ChatPanel must call SetKeyboardFocusHere when FocusInput requested."); - Assert.True(inputIdx >= 0, "ChatPanel must still render the InputTextSubmit field."); - Assert.True(focusIdx < inputIdx, "SetKeyboardFocusHere must precede the InputTextSubmit it targets."); - } - - [Fact] - public void Render_WithoutFocusInputCall_DoesNotEmitSetKeyboardFocusHere() - { - var panel = new ChatPanel(new ChatVM(new ChatLog())); - var renderer = new FakePanelRenderer(); - - panel.Render(new PanelContext(0.016f, new NullBus()), renderer); - - Assert.DoesNotContain(renderer.Calls, c => c.Method == "SetKeyboardFocusHere"); - } - - [Fact] - public void FocusInput_OnlyAffectsTheNextRender_OneShot() - { - var panel = new ChatPanel(new ChatVM(new ChatLog())); - - // Frame 1 — FocusInput requested → expect a SetKeyboardFocusHere. - var r1 = new FakePanelRenderer(); - panel.FocusInput(); - panel.Render(new PanelContext(0.016f, new NullBus()), r1); - Assert.Contains(r1.Calls, c => c.Method == "SetKeyboardFocusHere"); - - // Frame 2 — no further FocusInput call → must NOT re-fire. - var r2 = new FakePanelRenderer(); - panel.Render(new PanelContext(0.016f, new NullBus()), r2); - Assert.DoesNotContain(r2.Calls, c => c.Method == "SetKeyboardFocusHere"); - } -} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs deleted file mode 100644 index 77aac3f7..00000000 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs +++ /dev/null @@ -1,358 +0,0 @@ -using AcDream.Core.Chat; -using AcDream.UI.Abstractions.Panels.Chat; - -namespace AcDream.UI.Abstractions.Tests.Panels.Chat; - -/// -/// Phase I.4: when the user submits text via the chat input field, the -/// panel must publish the appropriate typed intent to the command bus. -/// We exercise the full Render path with the -/// pre-loading a "submitted" string and a recording bus capturing the -/// resulting command. -/// -public sealed class ChatPanelInputTests -{ - private sealed class RecordingBus : ICommandBus - { - public List Published { get; } = new(); - public void Publish(T command) where T : notnull => Published.Add(command); - } - - [Fact] - public void Submit_HelpCommand_RendersLocalHelpAndDoesNotPublish() - { - // Phase J follow-up: client-side commands (/help, /?, /h) are - // intercepted before the parser. They render local text via - // ChatLog.OnSystemMessage and do NOT round-trip the server — that's - // what prevented the "Unknown command: help" duplicate ACE was - // firing back. - // - // Campaign CH user-gate round 3 (2026-08-10): retail's DoHelp - // prints via exactly TWO scroll entries (Note, then the 13-item - // "Available help:" listing), never one acdream-invented blob — see - // RetailCommandHelpTable's class remarks for the full trace. - var log = new ChatLog(); - var vm = new ChatVM(log); - var panel = new ChatPanel(vm); - var bus = new RecordingBus(); - var renderer = new FakePanelRenderer - { - InputTextSubmitNextSubmitted = "/help", - InputTextSubmitNextBufferAfter = "", - }; - - panel.Render(new PanelContext(0.016f, bus), renderer); - - Assert.Empty(bus.Published); - var entries = log.Snapshot(); - Assert.Equal(2, entries.Length); - Assert.All(entries, entry => Assert.Equal(ChatKind.System, entry.Kind)); - Assert.Equal(RetailCommandHelpTable.HelpPrefixNote, entries[0].Text); - Assert.Equal(RetailCommandHelpTable.AvailableHelpListing, entries[1].Text); - } - - [Theory] - [InlineData("/?")] - // "/h" is DELETED (Campaign CH slice CH4, 2026-08-09) — it is not a - // retail-registered verb (registry doc §4's removal list). - [InlineData("/HELP")] - public void Submit_HelpAliases_AlsoRenderLocalHelp(string raw) - { - var log = new ChatLog(); - var vm = new ChatVM(log); - var panel = new ChatPanel(vm); - var bus = new RecordingBus(); - var renderer = new FakePanelRenderer - { - InputTextSubmitNextSubmitted = raw, - InputTextSubmitNextBufferAfter = "", - }; - - panel.Render(new PanelContext(0.016f, bus), renderer); - - Assert.Empty(bus.Published); - Assert.Equal(2, log.Snapshot().Length); - } - - [Fact] - public void Submit_FramerateCommand_PublishesTypedClientCommand() - { - var log = new ChatLog(); - var vm = new ChatVM(log) { FpsProvider = () => 60f }; - var panel = new ChatPanel(vm); - var bus = new RecordingBus(); - var renderer = new FakePanelRenderer - { - InputTextSubmitNextSubmitted = "/framerate", - InputTextSubmitNextBufferAfter = "", - }; - - panel.Render(new PanelContext(0.016f, bus), renderer); - - var command = Assert.IsType(Assert.Single(bus.Published)); - Assert.Equal(ClientCommandId.ToggleFrameRate, command.Command); - Assert.Empty(log.Snapshot()); - } - - [Fact] - public void Submit_LocCommand_PublishesTypedClientCommand() - { - var log = new ChatLog(); - var vm = new ChatVM(log) - { - PositionProvider = () => new System.Numerics.Vector3(10f, 20f, 30f), - }; - var panel = new ChatPanel(vm); - var bus = new RecordingBus(); - var renderer = new FakePanelRenderer - { - InputTextSubmitNextSubmitted = "@loc", - InputTextSubmitNextBufferAfter = "", - }; - - panel.Render(new PanelContext(0.016f, bus), renderer); - - var command = Assert.IsType(Assert.Single(bus.Published)); - Assert.Equal(ClientCommandId.ShowLocation, command.Command); - Assert.Empty(log.Snapshot()); - } - - [Theory] - [InlineData("/foo", "@foo")] - [InlineData("/genio public", "@genio public")] - public void Submit_UnknownSlashCommand_RoutesToExplicitServerCommand(string raw, string expectedText) - { - // Phase J Tier 4 held: /-prefixed text is still NEVER broadcast - // as plain speech. Retail treats / and @ as equivalent command - // prefixes, so unknown verbs now go to the SERVER as @commands - // (ACE's GameActionTalk intercepts @ on the Say action and - // answers "Unknown command: x" itself) instead of a local guess. - var log = new ChatLog(); - var vm = new ChatVM(log); - var panel = new ChatPanel(vm); - var bus = new RecordingBus(); - var renderer = new FakePanelRenderer - { - InputTextSubmitNextSubmitted = raw, - InputTextSubmitNextBufferAfter = "", - }; - - panel.Render(new PanelContext(0.016f, bus), renderer); - - var sendCmd = Assert.IsType(Assert.Single(bus.Published)); - Assert.Equal(expectedText, sendCmd.Text); - Assert.Empty(log.Snapshot()); // no local "Unknown command" guess - } - - [Theory] - [InlineData("/lifestone")] - [InlineData("/lif")] - [InlineData("/ls")] - [InlineData("@LS")] - public void Submit_LifestoneAlias_PublishesTypedClientCommand(string raw) - { - var vm = new ChatVM(new ChatLog()); - var panel = new ChatPanel(vm); - var bus = new RecordingBus(); - var renderer = new FakePanelRenderer - { - InputTextSubmitNextSubmitted = raw, - InputTextSubmitNextBufferAfter = "", - }; - - panel.Render(new PanelContext(0.016f, bus), renderer); - - var command = Assert.IsType(Assert.Single(bus.Published)); - Assert.Equal(ClientCommandId.LifestoneRecall, command.Command); - } - - [Theory] - [InlineData("/")] - [InlineData("//shrug")] - public void Submit_CommandShapedWithoutVerb_ShowsUnknownAndDoesNotPublish(string raw) - { - // Command-shaped but no letter verb: refused locally — this is - // the remaining Tier-4 guard (never broadcast /-text as speech, - // and don't put junk @-rewrites on the wire either). - var log = new ChatLog(); - var vm = new ChatVM(log); - var panel = new ChatPanel(vm); - var bus = new RecordingBus(); - var renderer = new FakePanelRenderer - { - InputTextSubmitNextSubmitted = raw, - InputTextSubmitNextBufferAfter = "", - }; - - panel.Render(new PanelContext(0.016f, bus), renderer); - - Assert.Empty(bus.Published); - var entry = Assert.Single(log.Snapshot()); - Assert.Equal(ChatKind.System, entry.Kind); - Assert.Contains("Unknown command", entry.Text); - Assert.Contains("/help", entry.Text); - } - - [Fact] - public void Submit_AtAcehelp_PublishesExplicitServerCommand() - { - // Unknown @-verb falls through to the default channel with the - // literal "@acehelp" text intact so ACE's CommandManager - // intercepts it server-side. The explicit server-command record keeps - // it distinct from ordinary Say text. - var log = new ChatLog(); - var vm = new ChatVM(log); - var panel = new ChatPanel(vm); - var bus = new RecordingBus(); - var renderer = new FakePanelRenderer - { - InputTextSubmitNextSubmitted = "@acehelp", - InputTextSubmitNextBufferAfter = "", - }; - - panel.Render(new PanelContext(0.016f, bus), renderer); - - var sendCmd = Assert.IsType(Assert.Single(bus.Published)); - Assert.Equal("@acehelp", sendCmd.Text); - } - - [Fact] - public void Submit_ClearCommand_PublishesTypedClientCommand() - { - var log = new ChatLog(); - log.OnSystemMessage("seed line", chatType: 0); - var vm = new ChatVM(log); - var panel = new ChatPanel(vm); - var bus = new RecordingBus(); - var renderer = new FakePanelRenderer - { - InputTextSubmitNextSubmitted = "/clear", - InputTextSubmitNextBufferAfter = "", - }; - - panel.Render(new PanelContext(0.016f, bus), renderer); - - var command = Assert.IsType(Assert.Single(bus.Published)); - Assert.Equal(ClientCommandId.ClearChat, command.Command); - Assert.Single(log.Snapshot()); - } - - [Fact] - public void Submit_PlainText_PublishesSayCommand() - { - var log = new ChatLog(); - var vm = new ChatVM(log); - var panel = new ChatPanel(vm); - var bus = new RecordingBus(); - var renderer = new FakePanelRenderer - { - InputTextSubmitNextSubmitted = "hello world", - InputTextSubmitNextBufferAfter = "", - }; - - panel.Render(new PanelContext(0.016f, bus), renderer); - - var cmd = Assert.Single(bus.Published); - var sendCmd = Assert.IsType(cmd); - Assert.Equal(ChatChannelKind.Say, sendCmd.Channel); - Assert.Null(sendCmd.TargetName); - Assert.Equal("hello world", sendCmd.Text); - } - - [Fact] - public void Submit_TellSlashCommand_PublishesTellCommand() - { - var log = new ChatLog(); - var vm = new ChatVM(log); - var panel = new ChatPanel(vm); - var bus = new RecordingBus(); - var renderer = new FakePanelRenderer - { - InputTextSubmitNextSubmitted = "/t Bestie ping", - InputTextSubmitNextBufferAfter = "", - }; - - panel.Render(new PanelContext(0.016f, bus), renderer); - - var sendCmd = Assert.IsType(Assert.Single(bus.Published)); - Assert.Equal(ChatChannelKind.Tell, sendCmd.Channel); - Assert.Equal("Bestie", sendCmd.TargetName); - Assert.Equal("ping", sendCmd.Text); - } - - [Fact] - public void Submit_ReplySlashCommand_UsesLastIncomingTellSender() - { - var log = new ChatLog(); - var vm = new ChatVM(log); - log.OnTellReceived("Bestie", "ping", senderGuid: 0x5000_00AAu, logTextType: 0x03u); - - var panel = new ChatPanel(vm); - var bus = new RecordingBus(); - var renderer = new FakePanelRenderer - { - InputTextSubmitNextSubmitted = "/r back at you", - InputTextSubmitNextBufferAfter = "", - }; - - panel.Render(new PanelContext(0.016f, bus), renderer); - - var sendCmd = Assert.IsType(Assert.Single(bus.Published)); - Assert.Equal(ChatChannelKind.Tell, sendCmd.Channel); - Assert.Equal("Bestie", sendCmd.TargetName); - Assert.Equal("back at you", sendCmd.Text); - } - - [Fact] - public void Submit_EmptyOrWhitespace_PublishesNothing() - { - var log = new ChatLog(); - var vm = new ChatVM(log); - var panel = new ChatPanel(vm); - var bus = new RecordingBus(); - var renderer = new FakePanelRenderer - { - InputTextSubmitNextSubmitted = " ", - InputTextSubmitNextBufferAfter = "", - }; - - panel.Render(new PanelContext(0.016f, bus), renderer); - - Assert.Empty(bus.Published); - } - - [Fact] - public void NoSubmit_PublishesNothing() - { - // Most frames: user is typing or idle; submitted == null. - var log = new ChatLog(); - var vm = new ChatVM(log); - var panel = new ChatPanel(vm); - var bus = new RecordingBus(); - var renderer = new FakePanelRenderer - { - InputTextSubmitNextSubmitted = null, - }; - - panel.Render(new PanelContext(0.016f, bus), renderer); - - Assert.Empty(bus.Published); - } - - [Fact] - public void Render_AlwaysCallsInputTextSubmit_ToShowTheField() - { - var log = new ChatLog(); - var vm = new ChatVM(log); - var panel = new ChatPanel(vm); - var bus = new RecordingBus(); - var renderer = new FakePanelRenderer - { - InputTextSubmitNextSubmitted = null, - }; - - panel.Render(new PanelContext(0.016f, bus), renderer); - - Assert.Contains(renderer.Calls, c => c.Method == "InputTextSubmit"); - } -} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelLayoutTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelLayoutTests.cs deleted file mode 100644 index 36904453..00000000 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelLayoutTests.cs +++ /dev/null @@ -1,129 +0,0 @@ -using AcDream.Core.Chat; -using AcDream.UI.Abstractions.Panels.Chat; - -namespace AcDream.UI.Abstractions.Tests.Panels.Chat; - -/// -/// Phase J Tier 3: must reserve footer -/// space for the separator + input field so the input stays anchored -/// at the bottom across window resizes (the user reported the input -/// disappearing when the window shrank). The pattern is the standard -/// ImGui chat-window layout: a scrollable child filling -/// (0, -footerHeight), then the separator + input below it. -/// -public sealed class ChatPanelLayoutTests -{ - private sealed class NoBus : ICommandBus - { - public void Publish(T command) where T : notnull { /* no-op */ } - } - - [Fact] - public void Render_OrderIs_Begin_BeginChild_EndChild_Separator_InputTextSubmit_End() - { - var log = new ChatLog(); - log.OnSystemMessage("seed", chatType: 0); - var vm = new ChatVM(log); - var panel = new ChatPanel(vm); - var renderer = new FakePanelRenderer(); - - panel.Render(new PanelContext(0.016f, new NoBus()), renderer); - - var methods = renderer.Calls.Select(c => c.Method).ToList(); - int beginIdx = methods.IndexOf("Begin"); - int beginChildIdx = methods.IndexOf("BeginChild"); - int endChildIdx = methods.IndexOf("EndChild"); - // L.0 follow-up: Copy-mode toggle adds a Separator above the - // chat tail, so multiple Separators now exist. The footer - // separator (the one we care about for input layout) is the - // LAST one — between EndChild and the input field. - int separatorIdx = methods.LastIndexOf("Separator"); - int inputSubmitIdx = methods.IndexOf("InputTextSubmit"); - int endIdx = methods.IndexOf("End"); - - // All present - Assert.True(beginIdx >= 0, "Begin missing"); - Assert.True(beginChildIdx >= 0, "BeginChild missing"); - Assert.True(endChildIdx >= 0, "EndChild missing"); - Assert.True(separatorIdx >= 0, "Separator missing"); - Assert.True(inputSubmitIdx >= 0, "InputTextSubmit missing"); - Assert.True(endIdx >= 0, "End missing"); - - // Order: Begin < BeginChild < EndChild < Separator < InputTextSubmit < End - Assert.True(beginIdx < beginChildIdx); - Assert.True(beginChildIdx < endChildIdx); - Assert.True(endChildIdx < separatorIdx); - Assert.True(separatorIdx < inputSubmitIdx); - Assert.True(inputSubmitIdx < endIdx); - } - - [Fact] - public void Render_BeginChild_ReservesNegativeFooterFromFrameHeight() - { - var log = new ChatLog(); - var vm = new ChatVM(log); - var panel = new ChatPanel(vm); - var renderer = new FakePanelRenderer { FrameHeightWithSpacingValue = 24f }; - - panel.Render(new PanelContext(0.016f, new NoBus()), renderer); - - // L.0 follow-up: the chat panel now wraps its body in an outer - // ##chatbody BeginChild (so empty-space clicks can't drag the - // parent window). The inner ##chattail BeginChild is the one - // that reserves the footer; that's what this test asserts. - var chattailCall = renderer.Calls.Single(c => c.Method == "BeginChild" - && (string)c.Args[0]! == "##chattail"); - var size = (System.Numerics.Vector2)chattailCall.Args[1]!; - // Width 0 = fill available; height < 0 = "fill minus this". - // Reserved height should equal FrameHeightWithSpacing + a small - // separator pad (~6f) so the input never visually clips the - // last chat line. - Assert.Equal(0f, size.X); - Assert.True(size.Y < 0, $"expected negative reserve, got {size.Y}"); - Assert.True(size.Y <= -24f, $"expected at least -24f reserve, got {size.Y}"); - } - - [Fact] - public void Render_NewEntries_ScrollsToBottom() - { - // First render establishes the baseline (no auto-scroll because - // _lastRenderedCount == lines.Count == 0). Then a second render - // after a new entry should fire SetScrollHereY(1.0f). - var log = new ChatLog(); - var vm = new ChatVM(log); - var panel = new ChatPanel(vm); - var renderer = new FakePanelRenderer(); - var ctx = new PanelContext(0.016f, new NoBus()); - - panel.Render(ctx, renderer); - Assert.DoesNotContain(renderer.Calls, c => c.Method == "SetScrollHereY"); - - // Append a new entry, render again — auto-scroll should fire. - log.OnLocalSpeech("Caith", "hello", senderGuid: 0xAA, isRanged: false, logTextType: 0x02u); - renderer.Calls.Clear(); - panel.Render(ctx, renderer); - - var scrollCall = renderer.Calls.Single(c => c.Method == "SetScrollHereY"); - Assert.Equal(1.0f, (float)scrollCall.Args[0]!); - } - - [Fact] - public void Render_NoNewEntries_DoesNotForceScroll() - { - var log = new ChatLog(); - log.OnSystemMessage("seed", chatType: 0); - var vm = new ChatVM(log); - var panel = new ChatPanel(vm); - var renderer = new FakePanelRenderer(); - var ctx = new PanelContext(0.016f, new NoBus()); - - // First render establishes count baseline (1 entry). The first - // render auto-scrolls because lines.Count (1) > _lastRenderedCount - // (0). Subsequent renders without new entries should NOT scroll. - panel.Render(ctx, renderer); - renderer.Calls.Clear(); - panel.Render(ctx, renderer); - - Assert.DoesNotContain(renderer.Calls, c => c.Method == "SetScrollHereY"); - } -} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMCombatTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMCombatTests.cs index 42bd5df4..6e544dd5 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMCombatTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMCombatTests.cs @@ -56,41 +56,6 @@ public sealed class ChatVMCombatTests Assert.Equal("Alice says, \"hi\"", line.Text); } - [Fact] - public void ChatPanel_RendersCombatLine_ViaTextColored() - { - var log = new ChatLog(); - var vm = new ChatVM(log); - log.OnLocalSpeech("Alice", "hi", senderGuid: 0xAA, isRanged: false, logTextType: 0x02u); - log.OnCombatLine("You hit Mosswart for 5 slashing damage (50.0%).", - logTextType: 0x06u, kind: CombatLineKind.Info); - - var panel = new ChatPanel(vm); - var bus = new RecordingChatBus(); - var renderer = new FakePanelRenderer { InputTextSubmitNextSubmitted = null }; - - panel.Render(new PanelContext(0.016f, bus), renderer); - - // Plain LocalSpeech entry → Text; combat entry → TextColored, now - // sourced from RetailChatColorTable (Campaign CH slice CH1) keyed - // by LogTextType, not ChatPanel.ColorForCombat's severity bucket. - // The 0x06 generic Combat slot (colorDarkRed) is passed explicitly - // above — a registered approximation of retail's per-message - // dispatch (register row AP-176), not a ChatLog default. - Assert.Contains(renderer.Calls, c => - c.Method == "Text" && (string?)c.Args[0] == "Alice says, \"hi\""); - var coloredCall = Assert.Single( - renderer.Calls, - c => c.Method == "TextColored"); - Assert.Equal( - "You hit Mosswart for 5 slashing damage (50.0%).", - (string?)coloredCall.Args[1]); - RetailChatColorTable.TryGetColor(0x06u, out var expectedColor); - Assert.Equal( - expectedColor, - (System.Numerics.Vector4)coloredCall.Args[0]!); - } - private sealed class RecordingChatBus : ICommandBus { public void Publish(T command) where T : notnull { /* no-op */ } From a330d50df9e59d914241936c7c9bdb29609802d7 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 10:33:26 +0200 Subject: [PATCH 23/43] fix(chat): the unseen-text indicator follows its authored per-state visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression from cbab79d7, which I introduced: the indicator stopped showing at all. Switching it from Visible to state-driven was half a correction — right about retail's mechanism, wrong about what makes this element appear. Measured, rather than reasoned about (LayoutDump gained --props for it): 0x1000048C state 13 Ghosted 0x3B = True -> hidden state 1 Normal 0x3B = False -> shown state 3 pressed 0x3B = False Dat property 0x3B is "Invisible", authored PER STATE, and it is what puts this element on screen. UiDatElement applies 0x3B on a state change; UiButton does not, and this element builds as a button — so driving the state alone left it hidden forever. The original Visible toggle was, by coincidence, exactly what the authored data prescribes. So the property is applied here rather than left unhonoured. That is the authored data, not a visibility hack layered over the state machinery. The state is still set, for the media it selects, but only on the way IN: TrySetRetailState(Ghosted) means Enabled = false, and disabling the button would also refuse the click that scrolls to the newest text — a second bug waiting behind the first. The test now pins VISIBILITY across the transitions instead of ActiveState. The previous test passed while the feature was broken because the fixture element carried no 0x3B, so the assertion could never see the property that actually decides this. It fails now if the state is driven without the visibility. Proper fix noted for later: UiButton should honour per-state 0x3B the way UiDatElement already does. That is a wider change than this regression wants. Solution builds clean; full hermetic gate green. Co-Authored-By: Claude Opus 5 --- .../UI/Layout/ChatWindowController.cs | 26 +++++++++++---- .../UI/Layout/ChatWindowControllerTests.cs | 33 ++++++++----------- tools/LayoutDump/Program.cs | 25 ++++++++++++++ 3 files changed, 58 insertions(+), 26 deletions(-) diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index 73ef8c5f..27fef4c8 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -940,14 +940,28 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta /// private void SetUnreadIndicatorState(bool unread) { - if (_unreadIndicator is not IUiDatStateful stateful) + if (_unreadIndicator is null) return; - stateful.TrySetRetailState( - unread ? UiButtonStateMachine.Normal : GhostedStateId); - } - /// Retail state 0xD, the id its own click handler sets. - private const uint GhostedStateId = 13u; + // Apply the element's OWN authored per-state visibility (dat property + // 0x3B, "Invisible"), measured on 0x1000048C as: + // + // state 13 Ghosted 0x3B = True -> hidden + // state 1 Normal 0x3B = False -> shown + // + // UiDatElement applies 0x3B on a state change; UiButton does not, and + // this element builds as a button. So the property is applied here + // rather than left unhonoured — this is the authored data, not a + // visibility hack layered over it. + _unreadIndicator.Visible = unread; + + // Set the state too, for the media it selects. Deliberately only on + // the way IN: TrySetRetailState(Ghosted) means Enabled = false, and + // disabling the button would also refuse the click that scrolls to + // the newest text. + if (unread && _unreadIndicator is IUiDatStateful stateful) + stateful.TrySetRetailState(UiButtonStateMachine.Normal); + } /// Aims the chat entry at and focuses it. internal void StartTell(string name) diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs index c6c9286e..d1223134 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs @@ -200,39 +200,32 @@ public class ChatWindowControllerTests } [Fact] - public void TheIndicatorIsDrivenByAuthoredStateNotVisibility() + public void TheIndicatorFollowsItsAuthoredPerStateVisibility() { - // Retail's own click handler ends in SetState(0xD) — Ghosted — which - // is also the element's authored default; the unread look is state 1 - // (Normal), whose media list carries SIX frames and is where the - // flashing comes from. Hiding the element instead would look almost - // right and could never blink. + // The element authors dat property 0x3B ("Invisible") per state, and + // it is what decides whether the thing is on screen: + // state 13 Ghosted 0x3B = True -> hidden + // state 1 Normal 0x3B = False -> shown + // (measured with LayoutDump --props on 0x2100006F). + // + // UiDatElement applies 0x3B on a state change; UiButton does not, and + // this element builds as a button — so driving the state ALONE leaves + // it hidden forever, which is exactly the regression this pins. ChatWindowController ctrl = BindController(); - UiElement indicator = Assert.IsAssignableFrom( ctrl.UnreadIndicatorForTest); - // Visibility is NOT the mechanism: the element stays visible and - // changes STATE. Ghosted authors no media on the real element, so it - // draws nothing without being hidden. - Assert.True(indicator.Visible); + Assert.False(indicator.Visible); // Ghosted at rest - var stateful = Assert.IsAssignableFrom(indicator); - Assert.Equal("Ghosted", ((UiButton)indicator).ActiveState); - - // A line arriving while scrolled up flips it to Normal — the state - // whose authored media carries the six flash frames. ctrl.Transcript.Scroll.SetExtents(contentHeight: 500, viewHeight: 100); ctrl.Transcript.Scroll.SetScrollY(0); ctrl.SetUnreadForTest(true); ctrl.UpdateUnreadIndicator(); - Assert.Equal("Normal", ((UiButton)indicator).ActiveState); + Assert.True(indicator.Visible); // Normal once text is unseen - // ...and returning to the bottom puts it back. ctrl.Transcript.Scroll.ScrollToEnd(); ctrl.UpdateUnreadIndicator(); - Assert.Equal("Ghosted", ((UiButton)indicator).ActiveState); - _ = stateful; + Assert.False(indicator.Visible); // back to Ghosted } [Fact] diff --git a/tools/LayoutDump/Program.cs b/tools/LayoutDump/Program.cs index 3bdd0615..255917ff 100644 --- a/tools/LayoutDump/Program.cs +++ b/tools/LayoutDump/Program.cs @@ -23,6 +23,7 @@ if (args.Length == 0) bool showStates = args.Contains("--states"); bool showColors = args.Contains("--colors"); +bool showProps = args.Contains("--props"); uint[] ids = args.Where(a => !a.StartsWith("--")) .Select(a => Convert.ToUInt32(a, a.StartsWith("0x") ? 16 : 10)) .ToArray(); @@ -141,6 +142,30 @@ void Print(ElementInfo e, int depth) } } + // Raw property ids per state — ToggleBehavior (0x0B) and RolloverEnabled + // (0x13) change how a button interprets a state change, so "which state did + // I set" is not the whole story. + if (showProps) + { + foreach (var (stateId, state) in e.States) + { + if (state.Properties.Values.Count == 0) + continue; + string ids = string.Join(", ", state.Properties.Values + .OrderBy(kv => kv.Key) + .Select(kv => $"0x{kv.Key:X2}={Describe(kv.Value)}")); + + static string Describe(UiPropertyValue v) => v.Kind switch + { + UiPropertyKind.Bool => v.BoolValue.ToString(), + UiPropertyKind.Integer => v.IntegerValue.ToString(), + UiPropertyKind.Enum => $"0x{v.UnsignedValue:X}", + _ => v.Kind.ToString(), + }; + Console.WriteLine($"{pad} state {stateId}: props {ids}"); + } + } + if (showStates && e.States.Count != 0) { string names = string.Join(", ", e.States From f44f7641b15039f495e399aef789a6c36dc42397 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 10:41:54 +0200 Subject: [PATCH 24/43] tools(LayoutDump): --props and --media, for questions the decomp cannot answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two dump modes, both added because a chat bug turned on authored data rather than code. --props prints each state's property ids WITH VALUES. It is what settled the unseen-text indicator regression: element 0x1000048C authors dat property 0x3B ("Invisible") per state — state 13 Ghosted 0x3B = True -> hidden state 1 Normal 0x3B = False -> shown — which is what actually puts that element on screen. No amount of reading the decomp would have produced those two booleans. --media prints the RAW media sequence per state off the LayoutDesc, because ElementInfo keeps only the FIRST image of a state (LayoutImporter.cs, the `!imageRead` guard) and an animation is therefore invisible above that level. It reports what it CANNOT do rather than implying an element has no media: raw descriptors only carry what an element overrides, and states usually come from a base element — for the indicator, base 0x10000527, which lives in a different layout entirely. Following that chain means reimplementing LayoutImporter's Resolve, so the mode says so instead of printing a misleading empty list. What the two modes established between them, for whoever picks up the blink: the media vocabulary includes MediaDescImage, MediaDescPause and MediaDescJump, so an authored animation is a SEQUENCE with waits and a loop — a small program, not a frame array. Supporting it means keeping the sequence through import and running a media player, not just widening StateMedia to a list. Co-Authored-By: Claude Opus 5 --- tools/LayoutDump/Program.cs | 49 +++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tools/LayoutDump/Program.cs b/tools/LayoutDump/Program.cs index 255917ff..38b8a2b0 100644 --- a/tools/LayoutDump/Program.cs +++ b/tools/LayoutDump/Program.cs @@ -46,6 +46,55 @@ if (root is null) Console.WriteLine($"layout 0x{ids[0]:X8}"); Print(root, 0); +int mediaAt = Array.IndexOf(args, "--media"); +if (mediaAt >= 0) +{ + // The RAW media sequence per state, straight off the LayoutDesc. ElementInfo + // keeps only the first image, so an animation is invisible above that level. + uint wanted = mediaAt + 1 < args.Length + ? Convert.ToUInt32(args[mediaAt + 1], 16) + : 0u; + // Layouts are not necessarily in Portal — go through the adapter, the same + // way LayoutImporter does. + var ld = adapter.Get(ids[0]); + if (ld is null) + { + Console.WriteLine($"layout 0x{ids[0]:X8} not found"); + return 2; + } + + foreach (var top in ld.Elements) + Walk(top.Value); + return 0; + + void Walk(DatReaderWriter.Types.ElementDesc d) + { + if (wanted == 0 || d.ElementId == wanted) + { + Console.WriteLine($"element 0x{d.ElementId:X8}"); + if (d.States.Count == 0) + { + // Raw descriptors only carry what THIS element overrides; states + // and their media usually come from the base element, and + // LayoutDesc::InqFullDesc @0x0069A520 resolves that chain. + // Following it here would mean reimplementing LayoutImporter's + // Resolve, so say so rather than imply the element has none. + Console.WriteLine( + $" (no states of its own — inherited from base 0x{d.BaseElement:X8};" + + " raw media not resolved here)"); + } + foreach (var st in d.States) + { + Console.WriteLine($" state {st.Key}: {st.Value.Media.Count} media"); + foreach (var m in st.Value.Media) + Console.WriteLine($" {m.GetType().Name}"); + } + } + foreach (var child in d.Children) + Walk(child.Value); + } +} + int resizeAt = Array.IndexOf(args, "--resize"); if (resizeAt >= 0 && resizeAt + 2 < args.Length) { From 89db9a794cd8499740806e9e50c7c75aefe9d8cc Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 11:14:53 +0200 Subject: [PATCH 25/43] feat(ui): authored state media animates, so the unseen-text indicator blinks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blink is not code. It is data, and we were throwing it away. A retail UI state's media is a small program: images interleaved with timed pauses, branches, and a terminal hand-off to another state. Our importer kept the FIRST image per state and dropped the rest, so nothing authored could ever animate — the indicator was correct in every other respect and simply sat still. Measured from the installed dats (LayoutDump --media 0x1000048C), the chat unseen-text indicator's Normal state authors thirteen steps: two frames alternating every half second, three times, then `State 13` — Ghosted, whose authored 0x3B is Invisible. So retail's indicator is a three-second attention FLASH that hides itself, not a badge that stays lit until you scroll to the bottom. Nobody would guess that from the code, because there is no blink code anywhere; the behaviour lives entirely in the authored sequence. Our shipped version stayed lit, which is the one thing the data says it must not do. Sampling is a pure function of (steps, elapsed) rather than a playback object holding a cursor, so an element only has to remember WHEN its state began and the whole thing is testable without a clock, a GPU or a frame loop. One shared UiMediaClock is advanced once per frame by RetailUiRuntime; a UI element has no tick of its own. The controller change is the other half: it starts the flash on the rising edge ONLY. Re-setting Normal every frame would pin the sequence on frame zero and it would never blink at all — which is the failure mode the second new test exists to catch, and which no "is it visible?" assertion would notice. When the sequence reaches its terminal step the controller follows it down instead of re-lighting it. Two guesses are refused rather than made, and both are registered: a Pause's max duration (every sequence measured sets min == max, and what the range MEANS is not in the decomp) and a sub-1 branch probability (falls through, the direction where a malformed sequence stops rather than animates forever). A jump-cycle with no elapsed time is bounded so a bad sequence cannot spin inside a frame. Kept `Other` steps in the list rather than filtering them, so a jump's authored index still lands on the entry it names. Register: CT-3, CT-4. Co-Authored-By: Claude Opus 5 --- .../retail-divergence-register.md | 4 +- .../UI/Layout/ChatWindowController.cs | 51 ++++-- src/AcDream.App/UI/Layout/LayoutImporter.cs | 25 +++ src/AcDream.App/UI/Layout/UiMediaSequence.cs | 158 ++++++++++++++++++ src/AcDream.App/UI/Layout/UiPropertyBag.cs | 46 +++++ src/AcDream.App/UI/RetailUiRuntime.cs | 3 + src/AcDream.App/UI/UiButton.cs | 79 ++++++++- .../UI/Layout/ChatWindowControllerTests.cs | 51 ++++++ .../UI/Layout/UiMediaSequenceTests.cs | 139 +++++++++++++++ tools/LayoutDump/Program.cs | 57 +++---- 10 files changed, 557 insertions(+), 56 deletions(-) create mode 100644 src/AcDream.App/UI/Layout/UiMediaSequence.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/UiMediaSequenceTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 86b2f37c..bedfc289 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-07-31 +# Retail Divergence Register — current through 2026-07-31 **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, @@ -492,6 +492,8 @@ equivalence argument (promote to AD/AP) or a fix. | UN-7 | Outdoor OBJECT point lighting uses `calc_point_light` (wrap/norm + per-channel cap, `~1/d²`) for ALL meshes including static buildings, but retail's object path is unconfirmed — `config_hardware_light` (0x0059ad30) sets D3D-FF point lights (`Diffuse=color×intensity`, `Attenuation=(0,1,0)`⇒`1/d`, `Range=falloff×1.5`, `material.diffuse=white`) yet that math would blow walls WHITE while retail stays DIM, so static buildings may instead use the `SetStaticLightingVertexColors` bake. Model + the brightness-scaling factor both UNRESOLVED (issue #140 / Fix D) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`) | Fix A/B ported calc_point_light + per-object selection for objects without confirming retail uses that model for static buildings; cdb captured the D3D-FF path but it contradicts the observed dim result | Outdoor buildings blow out warm near torches (the #140 meeting-hall symptom); whichever model is wrong, the object torch contribution is too strong | `config_hardware_light` 0x0059ad30; `SetStaticLightingVertexColors` 0x0059cfe0; `rangeAdjust=1.5` 0x00820cc4 — see docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md | | CT-1 | Transcript truncation uses ONE character threshold (10,000) where retail uses two — it beheads to ~7,500 (`0x1D4C`) on passing 10,000 (`0x2710`), so its buffer oscillates between the two. acdream also cuts at whole LINES rather than searching for a newline near a byte offset | `src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs` (`MaxTranscriptCharacters`, `FirstLineWithinBudget`) | Retail's hysteresis exists to avoid re-trimming an ACCUMULATING buffer on every append; we rebuild the visible list from the log each time, so there is nothing to damp and a second threshold would only make the oldest visible line jump around as messages arrive. Whole-line cutting is what retail's newline preference is trying to achieve — our unit already is the line | acdream shows up to ~2,500 characters more scrollback than retail at the moment retail has just trimmed. Visible only as a slightly longer history; no state, wire or memory effect (ChatLog's own entry cap still bounds the model) | `ChatInterface::TruncateChatLog @0x004F4290`; threshold read at `RecvNotice_DisplayFinalStringInfo @0x004F4640` | | CT-2 | No client-side chat word filtering. Retail runs every transcript line through a taboo table when the `FilterLanguage` option is on and SUBSTITUTES matches; acdream performs no substitution at all. The option itself is kept and still stores/ships its bit to the server exactly as retail does | `src/AcDream.Core.Net/GameEventWiring.cs` (no filter in the AddText path); option at `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs` | DELIBERATE PRODUCT DECISION by the user, 2026-08-21: "I do not want any censoring." Not an oversight and not a porting gap | A player who enables FilterLanguage expecting retail's behaviour sees unfiltered text. No state, wire or server-visible effect — the option bit is still sent, so anything the SERVER gates on it behaves normally | `PlayerModule::FilterLanguage` + `TabooTableAdaptor::CheckCensorsW @0x00682A30` inside `ClientSystem::AddTextToScroll @0x00563C50`; matching at `TabooTable::CreateCheckString @0x00681570` / `StringMatchesFilter @0x00681600` | +| CT-3 | A media `Pause` step holds for its `MinDuration`; retail authors a min AND a max and acdream ignores the max. Every sequence measured so far sets them equal, so nothing shipped is affected | `src/AcDream.App/UI/Layout/UiMediaSequence.cs` (`Sample`, the `Pause` case) | Whether the range means a random hold, a ramp, or a min-with-a-frame-budget ceiling is NOT determinable from the decomp, and picking one would be a guess dressed as a port. Using the min is the one reading that is right in every interpretation for the equal-valued case we can actually observe | A sequence authoring min != max would animate faster than retail. None does in the elements dumped so far; if one is found, the reading has to be measured before it is implemented | `MediaDescPause` in the LayoutDesc dat; playback at `UIElement::AnimateMedia` | +| CT-4 | A media `Jump`/`State` step with a probability below 1 FALLS THROUGH rather than branching; retail rolls for it | `src/AcDream.App/UI/Layout/UiMediaSequence.cs` (`Sample`) | The roll's distribution and its re-roll cadence (per visit? per state entry?) are not in the decomp. Falling through is the conservative direction: a sequence that ends early stops animating, where treating it as certain would animate forever and could pin a state that never hands off | A probabilistic sequence plays its deterministic tail instead of its branch. The chat indicator authors p=1 throughout, so it is exact there | `MediaDescJump{Probability}` / `MediaDescState{Probability}` in the LayoutDesc dat | --- diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index 27fef4c8..fb224e99 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -938,29 +938,46 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta /// capability; the state machinery here is right either way, and gains the /// animation for free once that lands. /// + /// + /// True once the authored flash has been started for the current batch of + /// unseen text, so it is not restarted every frame. + /// + private bool _flashStarted; + private void SetUnreadIndicatorState(bool unread) { if (_unreadIndicator is null) return; - // Apply the element's OWN authored per-state visibility (dat property - // 0x3B, "Invisible"), measured on 0x1000048C as: - // - // state 13 Ghosted 0x3B = True -> hidden - // state 1 Normal 0x3B = False -> shown - // - // UiDatElement applies 0x3B on a state change; UiButton does not, and - // this element builds as a button. So the property is applied here - // rather than left unhonoured — this is the authored data, not a - // visibility hack layered over it. - _unreadIndicator.Visible = unread; + if (!unread) + { + _unreadIndicator.Visible = false; + _flashStarted = false; + return; + } + + if (!_flashStarted) + { + // Rising edge: start the authored sequence. Set ONCE — restarting + // it every frame would hold it on frame zero and it would never + // appear to blink at all. + _flashStarted = true; + _unreadIndicator.Visible = true; + if (_unreadIndicator is IUiDatStateful starting) + starting.TrySetRetailState(UiButtonStateMachine.Normal); + return; + } + + // The sequence ENDS itself: after three blinks it hands off to + // Ghosted, whose authored 0x3B is Invisible. Follow that rather than + // holding the indicator lit — retail's is a transient attention-flash, + // not a badge that stays up until you scroll down. + if (_unreadIndicator is UiButton flashing + && string.Equals(flashing.ActiveState, "Ghosted", StringComparison.Ordinal)) + { + _unreadIndicator.Visible = false; + } - // Set the state too, for the media it selects. Deliberately only on - // the way IN: TrySetRetailState(Ghosted) means Enabled = false, and - // disabling the button would also refuse the click that scrolls to - // the newest text. - if (unread && _unreadIndicator is IUiDatStateful stateful) - stateful.TrySetRetailState(UiButtonStateMachine.Normal); } /// Aims the chat entry at and focuses it. diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs index 1429cedc..01f23454 100644 --- a/src/AcDream.App/UI/Layout/LayoutImporter.cs +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -609,6 +609,31 @@ public static class LayoutImporter MediaCount = sd.Media.Count, }; + // Keep the WHOLE sequence, in order. A state's media is a small + // program — images interleaved with pauses and jumps — and taking only + // the first image (below) reduces a blinking element to a still frame. + // Unrecognised entries are kept as Other so a jump's index still lands + // on the authored entry. + var steps = new List(sd.Media.Count); + foreach (var m in sd.Media) + { + steps.Add(m switch + { + MediaDescImage i => new UiMediaStep( + UiMediaStepKind.Image, i.File, (int)i.DrawMode, 0f, 0f, 0u, 0f), + MediaDescPause p => new UiMediaStep( + UiMediaStepKind.Pause, 0u, 0, p.MinDuration, p.MaxDuration, 0u, 0f), + MediaDescState st => new UiMediaStep( + UiMediaStepKind.State, 0u, 0, 0f, 0f, + (uint)st.StateId, st.Probability), + MediaDescJump j => new UiMediaStep( + UiMediaStepKind.Jump, 0u, 0, 0f, 0f, j.JumpItemIndex, j.Probability), + _ => new UiMediaStep( + UiMediaStepKind.Other, 0u, 0, 0f, 0f, 0u, 0f, (int)m.MediaType), + }); + } + state.MediaSteps = steps; + bool imageRead = false; foreach (var m in sd.Media) { diff --git a/src/AcDream.App/UI/Layout/UiMediaSequence.cs b/src/AcDream.App/UI/Layout/UiMediaSequence.cs new file mode 100644 index 00000000..2a38375b --- /dev/null +++ b/src/AcDream.App/UI/Layout/UiMediaSequence.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; + +namespace AcDream.App.UI.Layout; + +/// +/// The clock authored media animations are sampled against. +/// +/// +/// A single shared clock rather than a timer per element: sampling is a pure +/// function of (steps, elapsed), so an element only needs to remember WHEN its +/// state began. Advanced once per frame by the host — a UI element has no tick +/// of its own. +/// +public static class UiMediaClock +{ + /// Seconds since the host started advancing this clock. + public static double Seconds { get; private set; } + + public static void Advance(double deltaSeconds) + { + if (double.IsFinite(deltaSeconds) && deltaSeconds > 0d) + Seconds += deltaSeconds; + } + + /// Test seam: rewind to a known point. + internal static void ResetForTest() => Seconds = 0d; +} + +/// +/// Plays a retail UI state's media sequence. +/// +/// +/// +/// A state's media is a small program rather than a picture: images +/// interleaved with timed pauses, branches, and a terminal state hand-off. +/// The chat window's unseen-text indicator (0x1000048C) authors this, +/// measured from the installed dats: +/// +/// +/// [ 0] Image 0x06005F0E [ 1] Pause 0.5 +/// [ 2] Image 0x06005F0F [ 3] Pause 0.5 two frames alternating, +/// ... three times over three seconds +/// [12] State 13 (Ghosted) p=1 then it hides itself +/// +/// +/// So retail's indicator is a transient attention-flash, not a badge that +/// stays lit. That is behaviour nobody would guess from the code, because +/// there is no blink code — it is entirely in the data. +/// +/// +/// Sampling is a pure function of (steps, elapsed): no playback object holds +/// a cursor, so a caller only has to remember WHEN a state began. That keeps +/// the animation testable without a clock, a GPU or a frame loop. +/// +/// +public static class UiMediaSequence +{ + /// + /// Guards a malformed sequence whose jumps form a cycle with no elapsed + /// time — without it, such a sequence would spin forever inside one frame. + /// + private const int MaximumSteps = 512; + + /// + /// Whether does anything over time. A state whose + /// media is a single image is NOT an animation and must keep the ordinary + /// still-frame path. + /// + public static bool IsAnimated(IReadOnlyList? steps) + { + if (steps is null || steps.Count < 2) + return false; + + int images = 0; + foreach (UiMediaStep step in steps) + { + switch (step.Kind) + { + case UiMediaStepKind.Pause: + case UiMediaStepKind.Jump: + case UiMediaStepKind.State: + return true; + case UiMediaStepKind.Image when ++images > 1: + return true; + } + } + return false; + } + + /// + /// The frame showing at , and the state + /// the sequence hands off to if it has reached its end. + /// + /// + /// File is 0 when no image has been reached yet. + /// TransitionState is null until a terminal State step is due. + /// + public static (uint File, uint? TransitionState) Sample( + IReadOnlyList? steps, + float elapsedSeconds) + { + if (steps is null || steps.Count == 0) + return (0u, null); + + uint file = 0u; + float at = 0f; + int cursor = 0; + + for (int guard = 0; guard < MaximumSteps; guard++) + { + if (cursor < 0 || cursor >= steps.Count) + return (file, null); + + UiMediaStep step = steps[cursor]; + switch (step.Kind) + { + case UiMediaStepKind.Image: + file = step.File; + cursor++; + break; + + case UiMediaStepKind.Pause: + // Retail authors a min and a max; every sequence measured + // so far sets them equal. MinDuration is used, and the + // range is left unimplemented rather than guessed at — + // see the divergence register. + at += Math.Max(0f, step.MinDuration); + if (elapsedSeconds < at) + return (file, null); // still holding this frame + cursor++; + break; + + case UiMediaStepKind.Jump: + // A probability below 1 is a chance to branch. Treated as + // "always" would loop a sequence retail sometimes lets + // fall through, so anything uncertain falls through here + // instead — the conservative direction, since a sequence + // that ends early stops animating rather than animating + // forever. + if (step.Probability >= 1f) + cursor = (int)step.JumpIndex; + else + cursor++; + break; + + case UiMediaStepKind.State: + return (file, step.Probability >= 1f ? step.JumpIndex : null); + + default: + cursor++; + break; + } + } + + return (file, null); + } +} diff --git a/src/AcDream.App/UI/Layout/UiPropertyBag.cs b/src/AcDream.App/UI/Layout/UiPropertyBag.cs index 377a16d3..d81209c7 100644 --- a/src/AcDream.App/UI/Layout/UiPropertyBag.cs +++ b/src/AcDream.App/UI/Layout/UiPropertyBag.cs @@ -110,6 +110,45 @@ public sealed class UiPropertyBag /// Primary render-surface media for a retail UI state. public readonly record struct UiImageMedia(uint File, int DrawMode); +/// What one entry of a state's media sequence does. +public enum UiMediaStepKind +{ + /// Anything we do not act on (sound, movie, message, ...). + Other, + + /// Show this image. + Image, + + /// Hold the current image for a duration. + Pause, + + /// Branch to another entry — what makes a sequence loop. + Jump, + + /// Hand the element to another STATE when the sequence ends. + State, +} + +/// +/// One entry of a retail state's media list. +/// +/// +/// A state's media is a SEQUENCE, not a picture: images interleaved with +/// pauses and jumps, which is how retail authors a blinking or cycling +/// element. Unrecognised entries are kept as +/// rather than dropped, so a jump's +/// index still lands on the right entry. +/// +public readonly record struct UiMediaStep( + UiMediaStepKind Kind, + uint File, + int DrawMode, + float MinDuration, + float MaxDuration, + uint JumpIndex, + float Probability, + int RawType = 0); + /// /// Dat-independent state descriptor. DirectState uses /// so it cannot collide with UIStateId.Undef == 0. @@ -123,6 +162,12 @@ public sealed class UiStateInfo public bool PassToChildren; public uint IncorporationFlags; public UiImageMedia? Image; + + /// + /// The state's media list in authored order, or empty when it has none. + /// remains the first drawable image — the still frame. + /// + public IReadOnlyList MediaSteps = Array.Empty(); public UiCursorMedia? Cursor; public UiPropertyBag Properties = new(); @@ -155,6 +200,7 @@ public sealed class UiStateInfo PassToChildren = PassToChildren, IncorporationFlags = IncorporationFlags, Image = Image, + MediaSteps = MediaSteps, Cursor = Cursor, Properties = Properties.Clone(), MediaCount = MediaCount, diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index e666c753..864a9e96 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -851,6 +851,9 @@ public sealed class RetailUiRuntime : IDisposable public void Tick(double deltaSeconds) { + // Authored media sequences (a blinking indicator, any cycling element) + // are sampled against this one clock — UI elements have no tick. + Layout.UiMediaClock.Advance(deltaSeconds); FpsController?.Tick(); _vividTargetIndicator?.Tick(); _vitalsSideBySide?.Tick(); diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs index 1e5297ce..0f77bfd7 100644 --- a/src/AcDream.App/UI/UiButton.cs +++ b/src/AcDream.App/UI/UiButton.cs @@ -332,7 +332,22 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful /// Active state name, runtime-settable (e.g. Max/Min toggling Normal ↔ Minimized). /// Matches . /// - public string ActiveState { get; set; } = ""; + private string _activeState = ""; + private double _activeStateStartedAt; + + public string ActiveState + { + get => _activeState; + set + { + if (string.Equals(_activeState, value, StringComparison.Ordinal)) + return; + _activeState = value; + // An authored media sequence is timed from the moment its state is + // entered, so this is the only thing an element needs to remember. + _activeStateStartedAt = Layout.UiMediaClock.Seconds; + } + } public uint ActiveRetailStateId { @@ -592,18 +607,64 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful private static uint ActiveFile(ElementInfo mediaInfo, string mediaState) => mediaInfo.StateMedia.TryGetValue(mediaState, out var m) ? m.File : 0u; + /// + /// The frame this element's active state is showing right now, and the + /// state its sequence hands off to when it ends. + /// + /// + /// A state whose media is a single image is NOT routed through the player; + /// it keeps the still-frame path, so the overwhelming majority of buttons + /// are untouched by this. + /// + private uint AnimatedFile(ElementInfo mediaInfo, string mediaState, out uint? handOff) + { + handOff = null; + if (!TryFindStateNamed(mediaInfo, mediaState, out UiStateInfo? state) + || !Layout.UiMediaSequence.IsAnimated(state!.MediaSteps)) + { + return ActiveFile(mediaInfo, mediaState); + } + + (uint file, uint? transition) = Layout.UiMediaSequence.Sample( + state.MediaSteps, + (float)(Layout.UiMediaClock.Seconds - _activeStateStartedAt)); + handOff = transition; + return file != 0u ? file : ActiveFile(mediaInfo, mediaState); + } + + private static bool TryFindStateNamed( + ElementInfo mediaInfo, string mediaState, out UiStateInfo? state) + { + foreach (var (_, candidate) in mediaInfo.States) + { + if (string.Equals(candidate.Name, mediaState, StringComparison.Ordinal)) + { + state = candidate; + return true; + } + } + state = null; + return false; + } + protected override void OnDraw(UiRenderContext ctx) { SyncMediaStates(); + + // An authored media sequence can end by handing the element to another + // state (the chat unseen-text indicator blinks three times, then hands + // off to Ghosted). Collected here and applied AFTER the draw: changing + // state mid-draw would invalidate the very media being drawn. + uint? pendingHandOff = null; if (_faceSegments.Length != 0) { for (int i = 0; i < _faceSegments.Length; i++) { FaceSegment segment = _faceSegments[i]; - DrawFace( - ctx, - ActiveFile(segment.Info, _segmentMediaStates[i]), - segment.Rect(Width, Height)); + uint frame = AnimatedFile( + segment.Info, _segmentMediaStates[i], out uint? segmentHandOff); + DrawFace(ctx, frame, segment.Rect(Width, Height)); + pendingHandOff ??= segmentHandOff; } } else if (ColorKeyFaceResolver is { } colorKeyResolver) @@ -624,7 +685,8 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful } else { - uint file = FaceFileOverride ?? ActiveFile(_mediaInfo, _faceMediaState); + uint file = FaceFileOverride + ?? AnimatedFile(_mediaInfo, _faceMediaState, out pendingHandOff); if (file != 0) { var (tex, tw, th) = _resolve(file); @@ -640,6 +702,11 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful } } + // The sequence has run out and asked for another state. Applied here, + // after every face has drawn this frame. + if (pendingHandOff is { } handOffState) + TrySetRetailState(handOffState); + if (Label is { Length: > 0 } label && LabelFont is { } lf) { // GF-11c: LabelBox null (every pre-existing button) reduces boxX/ diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs index d1223134..93b53416 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs @@ -228,6 +228,57 @@ public class ChatWindowControllerTests Assert.False(indicator.Visible); // back to Ghosted } + [Fact] + public void TheAuthoredHandOffEndsTheFlashEvenWhileStillScrolledUp() + { + // The blink is not code, it is DATA: the Normal state's media authors + // six image frames, three pauses' worth of alternation, and then a + // State step back to Ghosted (measured with LayoutDump --media on + // 0x1000048C). So retail's indicator is a three-second attention + // FLASH that hides itself — not a badge that stays lit until you + // scroll down. The controller has to let the sequence finish rather + // than re-lighting it every frame. + ChatWindowController ctrl = BindController(); + var indicator = Assert.IsType(ctrl.UnreadIndicatorForTest); + + ctrl.Transcript.Scroll.SetExtents(contentHeight: 500, viewHeight: 100); + ctrl.Transcript.Scroll.SetScrollY(0); + ctrl.SetUnreadForTest(true); + ctrl.UpdateUnreadIndicator(); + Assert.True(indicator.Visible); + + // Stand in for the sequence reaching its terminal State step. + indicator.TrySetRetailState(UiButtonStateMachine.Ghosted); + ctrl.UpdateUnreadIndicator(); + + Assert.False(indicator.Visible); + Assert.False(ctrl.Transcript.Scroll.AtEnd); // still scrolled up + } + + [Fact] + public void TheFlashIsStartedOnceRatherThanEveryFrame() + { + // Re-setting Normal every frame would restart the sequence, pinning it + // on frame zero — it would sit there lit and never blink at all. That + // is the whole bug this pins, and it is invisible to a test that only + // checks the indicator is showing. + ChatWindowController ctrl = BindController(); + var indicator = Assert.IsType(ctrl.UnreadIndicatorForTest); + + ctrl.Transcript.Scroll.SetExtents(contentHeight: 500, viewHeight: 100); + ctrl.Transcript.Scroll.SetScrollY(0); + ctrl.SetUnreadForTest(true); + ctrl.UpdateUnreadIndicator(); + + // Mid-sequence the player owns the state; the controller must not + // touch it again until the unseen flag is cleared and re-raised. + indicator.TrySetRetailState(UiButtonStateMachine.Ghosted); + for (int frame = 0; frame < 5; frame++) + ctrl.UpdateUnreadIndicator(); + + Assert.Equal("Ghosted", indicator.ActiveState); + } + [Fact] public void ClickingTheIndicatorJumpsToTheNewestText() { diff --git a/tests/AcDream.App.Tests/UI/Layout/UiMediaSequenceTests.cs b/tests/AcDream.App.Tests/UI/Layout/UiMediaSequenceTests.cs new file mode 100644 index 00000000..c033a37e --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/UiMediaSequenceTests.cs @@ -0,0 +1,139 @@ +using System.Collections.Generic; +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Retail's state media is a small program, and this plays it. +/// +public sealed class UiMediaSequenceTests +{ + private static UiMediaStep Image(uint file) + => new(UiMediaStepKind.Image, file, 1, 0f, 0f, 0u, 0f); + + private static UiMediaStep Pause(float seconds) + => new(UiMediaStepKind.Pause, 0u, 0, seconds, seconds, 0u, 0f); + + private static UiMediaStep State(uint state, float probability = 1f) + => new(UiMediaStepKind.State, 0u, 0, 0f, 0f, state, probability); + + private static UiMediaStep Jump(uint index, float probability = 1f) + => new(UiMediaStepKind.Jump, 0u, 0, 0f, 0f, index, probability); + + /// + /// The chat unseen-text indicator's authored sequence, verbatim from the + /// installed dats (0x2100006F / 0x1000048C, state 1): two frames + /// alternating every half second, three times, then hand off to Ghosted. + /// + private static IReadOnlyList BlinkSequence() => + [ + Image(0x06005F0Eu), Pause(0.5f), + Image(0x06005F0Fu), Pause(0.5f), + Image(0x06005F0Eu), Pause(0.5f), + Image(0x06005F0Fu), Pause(0.5f), + Image(0x06005F0Eu), Pause(0.5f), + Image(0x06005F0Fu), Pause(0.5f), + State(13u), + ]; + + [Theory] + [InlineData(0.0f, 0x06005F0Eu)] + [InlineData(0.4f, 0x06005F0Eu)] + [InlineData(0.5f, 0x06005F0Fu)] // first flip, exactly on the boundary + [InlineData(0.9f, 0x06005F0Fu)] + [InlineData(1.0f, 0x06005F0Eu)] + [InlineData(2.75f, 0x06005F0Fu)] + public void TheFrameAlternatesEveryHalfSecond(float elapsed, uint expected) + { + (uint file, uint? transition) = UiMediaSequence.Sample(BlinkSequence(), elapsed); + + Assert.Equal(expected, file); + Assert.Null(transition); + } + + [Fact] + public void AfterThreeSecondsItHandsOffToGhosted() + { + // This is the behaviour nobody would guess from the code: retail's + // indicator is a transient attention-flash, not a badge that stays + // lit. Three seconds of blinking, then it hides itself. + (uint _, uint? transition) = UiMediaSequence.Sample(BlinkSequence(), 3.0f); + + Assert.Equal(13u, transition); + } + + [Fact] + public void TheHandOffDoesNotFireEarly() + { + Assert.Null(UiMediaSequence.Sample(BlinkSequence(), 2.99f).TransitionState); + } + + [Fact] + public void ASingleImageIsNotAnAnimation() + { + // A still frame must keep the ordinary draw path rather than being + // routed through a player that would only ever return the same file. + Assert.False(UiMediaSequence.IsAnimated([Image(1u)])); + Assert.False(UiMediaSequence.IsAnimated([])); + Assert.False(UiMediaSequence.IsAnimated(null)); + + Assert.True(UiMediaSequence.IsAnimated([Image(1u), Pause(1f)])); + Assert.True(UiMediaSequence.IsAnimated([Image(1u), Image(2u)])); + } + + [Fact] + public void AJumpLoopsAndKeepsRunningForever() + { + IReadOnlyList looping = + [ + Image(0xAAu), Pause(1f), + Image(0xBBu), Pause(1f), + Jump(0u), + ]; + + Assert.Equal(0xAAu, UiMediaSequence.Sample(looping, 0.5f).File); + Assert.Equal(0xBBu, UiMediaSequence.Sample(looping, 1.5f).File); + Assert.Equal(0xAAu, UiMediaSequence.Sample(looping, 2.5f).File); // looped + Assert.Equal(0xBBu, UiMediaSequence.Sample(looping, 101.5f).File); // still going + } + + [Fact] + public void AZeroTimeJumpCycleTerminatesInsteadOfHanging() + { + // A malformed sequence — a jump cycle with no pause in it — would spin + // forever inside one frame. It must return, not hang the client. + IReadOnlyList pathological = [Image(0xAAu), Jump(0u)]; + + Assert.Equal(0xAAu, UiMediaSequence.Sample(pathological, 1f).File); + } + + [Fact] + public void AnUncertainBranchFallsThroughRatherThanLooping() + { + // Falling through ends the sequence; treating it as "always" would + // animate forever. The conservative direction is to stop. + IReadOnlyList maybe = + [ + Image(0xAAu), Pause(1f), Jump(0u, probability: 0.5f), Image(0xBBu), + ]; + + Assert.Equal(0xBBu, UiMediaSequence.Sample(maybe, 1.5f).File); + } + + [Fact] + public void UnknownStepsAreSteppedOverWithoutBreakingJumpIndices() + { + // Sound, movie and message entries are kept as Other precisely so a + // jump's index still lands on the authored entry. + IReadOnlyList withOther = + [ + new(UiMediaStepKind.Other, 0u, 0, 0f, 0f, 0u, 0f), + Image(0xCCu), + Pause(1f), + Jump(1u), + ]; + + Assert.Equal(0xCCu, UiMediaSequence.Sample(withOther, 0.5f).File); + Assert.Equal(0xCCu, UiMediaSequence.Sample(withOther, 5f).File); + } +} diff --git a/tools/LayoutDump/Program.cs b/tools/LayoutDump/Program.cs index 38b8a2b0..ac6336dd 100644 --- a/tools/LayoutDump/Program.cs +++ b/tools/LayoutDump/Program.cs @@ -49,49 +49,42 @@ Print(root, 0); int mediaAt = Array.IndexOf(args, "--media"); if (mediaAt >= 0) { - // The RAW media sequence per state, straight off the LayoutDesc. ElementInfo - // keeps only the first image, so an animation is invisible above that level. + // The RESOLVED media sequence per state — inheritance already applied by + // ImportInfos, which is what the raw LayoutDesc walk could not do. uint wanted = mediaAt + 1 < args.Length ? Convert.ToUInt32(args[mediaAt + 1], 16) : 0u; - // Layouts are not necessarily in Portal — go through the adapter, the same - // way LayoutImporter does. - var ld = adapter.Get(ids[0]); - if (ld is null) - { - Console.WriteLine($"layout 0x{ids[0]:X8} not found"); - return 2; - } - - foreach (var top in ld.Elements) - Walk(top.Value); + WalkMedia(root); return 0; - void Walk(DatReaderWriter.Types.ElementDesc d) + void WalkMedia(ElementInfo e) { - if (wanted == 0 || d.ElementId == wanted) + if (wanted == 0 || e.Id == wanted) { - Console.WriteLine($"element 0x{d.ElementId:X8}"); - if (d.States.Count == 0) + Console.WriteLine($"element 0x{e.Id:X8}"); + foreach (var (stateId, st) in e.States.OrderBy(kv => kv.Key)) { - // Raw descriptors only carry what THIS element overrides; states - // and their media usually come from the base element, and - // LayoutDesc::InqFullDesc @0x0069A520 resolves that chain. - // Following it here would mean reimplementing LayoutImporter's - // Resolve, so say so rather than imply the element has none. + if (st.MediaSteps.Count == 0) continue; Console.WriteLine( - $" (no states of its own — inherited from base 0x{d.BaseElement:X8};" - + " raw media not resolved here)"); - } - foreach (var st in d.States) - { - Console.WriteLine($" state {st.Key}: {st.Value.Media.Count} media"); - foreach (var m in st.Value.Media) - Console.WriteLine($" {m.GetType().Name}"); + $" state {stateId}{(st.Name.Length != 0 ? $" ({st.Name})" : "")}" + + $": {st.MediaSteps.Count} steps"); + for (int i = 0; i < st.MediaSteps.Count; i++) + { + UiMediaStep m = st.MediaSteps[i]; + string detail = m.Kind switch + { + UiMediaStepKind.Image => $"file=0x{m.File:X8} draw={m.DrawMode}", + UiMediaStepKind.Pause => $"min={m.MinDuration} max={m.MaxDuration}", + UiMediaStepKind.Jump => $"to={m.JumpIndex} p={m.Probability}", + UiMediaStepKind.State => $"state={m.JumpIndex} p={m.Probability}", + _ => $"MediaType={(DatReaderWriter.Enums.MediaType)m.RawType}", + }; + Console.WriteLine($" [{i,2}] {m.Kind,-6} {detail}"); + } } } - foreach (var child in d.Children) - Walk(child.Value); + foreach (ElementInfo child in e.Children) + WalkMedia(child); } } From 0e0a77c9b1756567084d67ff4483437bdd299fca Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 11:28:20 +0200 Subject: [PATCH 26/43] =?UTF-8?q?feat(chat):=20CT-B4=20=E2=80=94=20@log,?= =?UTF-8?q?=20and=20the=20research=20block=20that=20was=20a=20wrong=20ques?= =?UTF-8?q?tion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CT-B4 was filed as "the plain-text session chat log, path and rotation UNKNOWN, needs a live check." Both unknowns dissolve once you read the handler: there is no automatic session log. Retail's @log is a COMMAND. DoSetOutput @0x0057E4F0 takes a filename, StartCopyOutputToFile @0x0057C8A0 does the fopen(name, "a+"), and running it again with no argument closes it. Nothing rotates because it appends forever, and nothing has a fixed path because the player names the file. The path question that DOES exist — where a bare name lands — was answered all along by retail's own help text, which CH4 extracted verbatim into our help table a fortnight ago and nobody read: "a log file named Aclog.txt in your Asheron's Call directory." A blocked question sat on top of a committed answer. We cannot use the install directory: the launcher replaces it atomically on update, so a log written there is wiped by the next update or blocks it. The client's own log directory is the equivalent that survives. Rooted paths are honoured verbatim, as retail's fopen would. Register CT-5. The verb was registered in the help table but NOT in the command catalog, so /log printed help and did nothing — and the CH4 conformance registry recorded it as a "server passthrough" precisely because that shape is indistinguishable from an unimplemented client command. It never went on the wire at all. Both are corrected, with the totals moved in the same commit rather than left to drift. Moving it into the catalog also moves which help table answers for it, so retail's real text moved to the catalog-verb table in the same change. Without that, /help log would have silently started printing acdream's own invented one-line summary — caught by the coverage test, and now pinned by a test that names the text. All five replies are byte-decoded from the PDB-paired binary rather than read off Binary Ninja's previews, which truncate at ~33 characters and would have lost the second half of every one of them (including the two spaces retail puts after "Copying chat to %s."). The writer attaches on OPEN, not at startup — retail's help is explicit that only what appears after the command is copied — and detaches from the transcript it actually attached to, so a session teardown cannot leave a live handler writing into a file the player believes is closed. What gets written is the composed display line with the shared timestamp, because retail's fprintf sits inside AddTextToScroll: downstream of composition, upstream of glyph layout. Logging the raw entry text would have produced a file of bare fragments with no speakers. acdream's logs carry no inline tag markup where retail's do, since tags live beside the text as spans here rather than inside it. Registered as CT-6 rather than reconstructed purely to write it to a file. Register: CT-5, CT-6. Co-Authored-By: Claude Opus 5 --- .../retail-divergence-register.md | 2 + .../2026-08-21-chat-text-tag-campaign.md | 46 +++-- .../Net/LiveSessionCommandRouter.cs | 3 + .../Net/LiveSessionRuntimeFactory.cs | 53 ++++- src/AcDream.App/UI/ChatTranscriptLogWriter.cs | 73 +++++++ src/AcDream.App/UI/ClientCommandController.cs | 37 ++++ src/AcDream.Core/Chat/ChatSessionLog.cs | 184 ++++++++++++++++++ src/AcDream.Runtime/Chat/ClientCommandId.cs | 6 + .../Chat/RetailClientCommandCatalog.cs | 10 + .../Chat/RetailCommandHelpTable.cs | 6 + .../Net/LiveSessionCommandRouterTests.cs | 1 + .../UI/ChatTranscriptLogWriterTests.cs | 121 ++++++++++++ .../UI/ClientCommandControllerTests.cs | 133 ++++++++++++- .../Chat/ChatSessionLogTests.cs | 142 ++++++++++++++ .../Chat/RetailCommandHelpTableTests.cs | 17 +- .../RetailCommandRegistryConformanceTests.cs | 15 +- 16 files changed, 831 insertions(+), 18 deletions(-) create mode 100644 src/AcDream.App/UI/ChatTranscriptLogWriter.cs create mode 100644 src/AcDream.Core/Chat/ChatSessionLog.cs create mode 100644 tests/AcDream.App.Tests/UI/ChatTranscriptLogWriterTests.cs create mode 100644 tests/AcDream.Core.Tests/Chat/ChatSessionLogTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index bedfc289..720737ee 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -494,6 +494,8 @@ equivalence argument (promote to AD/AP) or a fix. | CT-2 | No client-side chat word filtering. Retail runs every transcript line through a taboo table when the `FilterLanguage` option is on and SUBSTITUTES matches; acdream performs no substitution at all. The option itself is kept and still stores/ships its bit to the server exactly as retail does | `src/AcDream.Core.Net/GameEventWiring.cs` (no filter in the AddText path); option at `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs` | DELIBERATE PRODUCT DECISION by the user, 2026-08-21: "I do not want any censoring." Not an oversight and not a porting gap | A player who enables FilterLanguage expecting retail's behaviour sees unfiltered text. No state, wire or server-visible effect — the option bit is still sent, so anything the SERVER gates on it behaves normally | `PlayerModule::FilterLanguage` + `TabooTableAdaptor::CheckCensorsW @0x00682A30` inside `ClientSystem::AddTextToScroll @0x00563C50`; matching at `TabooTable::CreateCheckString @0x00681570` / `StringMatchesFilter @0x00681600` | | CT-3 | A media `Pause` step holds for its `MinDuration`; retail authors a min AND a max and acdream ignores the max. Every sequence measured so far sets them equal, so nothing shipped is affected | `src/AcDream.App/UI/Layout/UiMediaSequence.cs` (`Sample`, the `Pause` case) | Whether the range means a random hold, a ramp, or a min-with-a-frame-budget ceiling is NOT determinable from the decomp, and picking one would be a guess dressed as a port. Using the min is the one reading that is right in every interpretation for the equal-valued case we can actually observe | A sequence authoring min != max would animate faster than retail. None does in the elements dumped so far; if one is found, the reading has to be measured before it is implemented | `MediaDescPause` in the LayoutDesc dat; playback at `UIElement::AnimateMedia` | | CT-4 | A media `Jump`/`State` step with a probability below 1 FALLS THROUGH rather than branching; retail rolls for it | `src/AcDream.App/UI/Layout/UiMediaSequence.cs` (`Sample`) | The roll's distribution and its re-roll cadence (per visit? per state entry?) are not in the decomp. Falling through is the conservative direction: a sequence that ends early stops animating, where treating it as certain would animate forever and could pin a state that never hands off | A probabilistic sequence plays its deterministic tail instead of its branch. The chat indicator authors p=1 throughout, so it is exact there | `MediaDescJump{Probability}` / `MediaDescState{Probability}` in the LayoutDesc dat | +| CT-5 | A bare `@log` filename lands in the client's own log directory (`ApplicationPathSet.LogsDirectory`), not the install directory retail names ("a log file named Aclog.txt in your Asheron's Call directory"). Rooted paths are honoured verbatim, as retail's `fopen` would | `src/AcDream.App/Net/LiveSessionRuntimeFactory.cs` (`_chatLogDirectory`); `src/AcDream.Core/Chat/ChatSessionLog.cs` | acdream's launcher replaces the install directory atomically on update, so a log written there is wiped by the next update or blocks it outright. Retail had no updater with that property. The client's own data directory is the equivalent that survives | A player following retail-era instructions looks for the file next to the executable and does not find it. The `/log` reply names the file, not the directory, so the path is discoverable only from this row and the code | `ClientCommunicationSystem::StartCopyOutputToFile @0x0057C8A0`; help text at `DoSetOutputHelp @0x0057A950` | +| CT-6 | The `@log` file records the composed line WITHOUT retail's inline text-tag markup. Retail's `fprintf` runs before glyph parsing, so its logs contain literal `` markers around tagged names | `src/AcDream.App/UI/ChatTranscriptLogWriter.cs` | acdream never puts markup in the line: `ChatVM` carries tags as SPANS beside the text (CT-A2/A3), so there is no markup at that seam to preserve. Reconstructing it purely to write it to a file would be inventing a string the client does not otherwise produce | A log diffed against a retail-era log differs on tagged lines — acdream's are the clean ones. No in-client effect | `ClientSystem::AddTextToScroll` write at `@0x00563E5B`, upstream of `UIElement_Text::InqGlyphs @0x00468EA0` | --- diff --git a/docs/plans/2026-08-21-chat-text-tag-campaign.md b/docs/plans/2026-08-21-chat-text-tag-campaign.md index b8cde931..d81c7871 100644 --- a/docs/plans/2026-08-21-chat-text-tag-campaign.md +++ b/docs/plans/2026-08-21-chat-text-tag-campaign.md @@ -1,14 +1,17 @@ # Campaign CT — complete chat parity (system + GUI) -**Status:** Groups A, B, C and D COMPLETE 2026-08-21, each user-gated. Two -items deliberately not shipped: CT-B3 (word filtering — dropped by user -direction, register row CT-2) and CT-B4 (session chat log — research-blocked). +**Status:** Groups A, B, C and D COMPLETE 2026-08-21, each user-gated. +CT-B4 landed 2026-08-21 after the research block turned out to rest on a wrong +premise (see the slice). One item deliberately not shipped: CT-B3 (word +filtering — dropped by user direction, register row CT-2). -**Carried forward:** multi-frame state media. Retail's unseen-text indicator -blinks because its `Normal` state authors SIX image frames; our importer keeps -one file per state, so nothing authored can animate. The indicator is -state-driven and correct today and gains the blink for free once that -capability lands. It is the first known customer, not the only one. +**Carried forward:** ~~multi-frame state media~~ **DONE 2026-08-21.** The +importer now keeps the whole authored sequence and `UiMediaSequence` plays it. +Measuring the real data (`LayoutDump --media 0x1000048C`) corrected the +behaviour as well as enabling it: the indicator blinks three times over three +seconds and then hands off to `Ghosted`, hiding itself. Retail's is a transient +attention-flash, not a badge that stays lit until you scroll down. Register +rows CT-3, CT-4. **Goal, set by the user 2026-08-21: complete retail parity for the chat system AND the chat GUI.** Not "fix the green name" — that was the symptom @@ -127,9 +130,29 @@ Nothing is user-visible until A4. `TabooTable::CreateCheckString @0x00681570` normalises a candidate before `StringMatchesFilter @0x00681600` compares it, which is how retail catches obfuscated spellings. -- **CT-B4** The plain-text session chat log (`ClientSystem::s_pLogFile`). We - write none. **Research-blocked**: path and rotation are UNKNOWN and are not - in the decomp — needs a live check or a dat/filesystem probe. +- **CT-B4** ~~The plain-text session chat log~~ **DONE — and the premise was + wrong.** There is no automatic session log to have a path for. Retail's + `@log` is a COMMAND: `ClientCommunicationSystem::DoSetOutput @0x0057E4F0` + takes a filename, `StartCopyOutputToFile @0x0057C8A0` does the + `fopen(name, "a+")`, and running it again with no argument closes it. So + "path and rotation UNKNOWN" was asking a question the design does not have: + the player names the file, and there is no rotation because it appends + forever. + + The path question that DOES exist — where a bare name lands — is answered by + retail's own help text, which the CH4 help table already carried verbatim + without anyone reading it: "a log file named Aclog.txt **in your Asheron's + Call directory**". acdream cannot use the install directory (the launcher + replaces it atomically on update), so a bare name lands in the client's own + log directory. Rooted paths are honoured verbatim. Register row CT-5. + + Landed with it: the verb registered in the catalog (it had a help entry + since CH4 but no catalog entry, so `/log` printed help and did nothing), + retail's `.txt`-for-extensionless rule, all five reply strings byte-decoded + from the paired binary, and the writer attached at OPEN so only text after + the command is copied. The line logged is the composed display line with the + shared timestamp, because retail's `fprintf` sits inside `AddTextToScroll` + — downstream of composition, upstream of glyph layout. ### Group C — chat GUI @@ -166,7 +189,6 @@ Nothing is user-visible until A4. Blocks nothing in Group A, but decides whether other tag shapes exist. - Whether retail's transcript supports text selection distinctly from the entry field (blocks CT-C4's scope). -- The chat log file's path and rotation (blocks CT-B4). - Whether a chat-specific sound cue exists — a grep came back empty, which is weak evidence, not proof of absence. diff --git a/src/AcDream.App/Net/LiveSessionCommandRouter.cs b/src/AcDream.App/Net/LiveSessionCommandRouter.cs index 2d781197..9b7aa83a 100644 --- a/src/AcDream.App/Net/LiveSessionCommandRouter.cs +++ b/src/AcDream.App/Net/LiveSessionCommandRouter.cs @@ -382,6 +382,9 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting InvokeClient(b => b.ShowConfirmation(text, callback)), Suicide: () => InvokeClient(static b => b.Suicide()), ClearChat: all => InvokeClient(b => b.ClearChat(all)), + SetChatLogFile: name => ReadClient( + b => b.SetChatLogFile(name), + default(AcDream.Core.Chat.ChatLogResult)), SaveUi: name => InvokeClient(b => b.SaveUi(name)), LoadUi: name => InvokeClient(b => b.LoadUi(name)), SaveAutoUi: () => InvokeClient(static b => b.SaveAutoUi()), diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index f5af448e..6460bc14 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -115,6 +115,16 @@ internal sealed class LiveSessionRuntimeFactory private readonly TimeSpan _loginCommandDelay; private readonly TimeProvider _timeProvider; + /// + /// Where a bare @log filename lands. See + /// for why this is not the install directory retail names. + /// + private readonly string _chatLogDirectory; + + private ChatSessionLog? _chatSessionLog; + + private ChatTranscriptLogWriter? _chatLogWriter; + public LiveSessionRuntimeFactory( LiveSessionPlayerRuntime player, LiveSessionDomainRuntime domain, @@ -127,7 +137,8 @@ internal sealed class LiveSessionRuntimeFactory string sessionId = "app", IReadOnlyList? loginCommands = null, int loginCommandDelayMs = 500, - TimeProvider? timeProvider = null) + TimeProvider? timeProvider = null, + string? chatLogDirectory = null) { _player = player ?? throw new ArgumentNullException(nameof(player)); _domain = domain ?? throw new ArgumentNullException(nameof(domain)); @@ -146,6 +157,8 @@ internal sealed class LiveSessionRuntimeFactory throw new ArgumentOutOfRangeException( nameof(loginCommandDelayMs)); } + _chatLogDirectory = chatLogDirectory + ?? AcDream.Platform.ApplicationPathSet.Resolve().LogsDirectory; _loginCommands = loginCommands is null ? [] : [.. loginCommands]; _loginCommandDelay = TimeSpan.FromMilliseconds(loginCommandDelayMs); _timeProvider = timeProvider ?? TimeProvider.System; @@ -252,6 +265,43 @@ internal sealed class LiveSessionRuntimeFactory connectOptions); } + /// + /// Retail's @log file lifecycle + /// (ClientCommunicationSystem::StartCopyOutputToFile @0x0057C8A0 / + /// CloseLogFile @0x0057ACC0). An empty name closes. + /// + /// + /// The writer attaches to the transcript on OPEN rather than at startup, + /// which is what retail's own help promises: "All the information that + /// appears in your chat window AFTER you type this command will be copied". + /// It detaches on close, so a closed log costs nothing per line. + /// + /// The line written is the composed display line, because that is what + /// retail logs — fprintf @0x00563E5B sits inside + /// AddTextToScroll, downstream of composition and upstream of glyph + /// layout. is acdream's equivalent single + /// fan-in, and it already owns the timestamp decision the log shares. + /// + /// + private ChatLogResult SetChatLogFile(string name) + { + ChatSessionLog log = _chatSessionLog ??= new ChatSessionLog(_chatLogDirectory); + ChatTranscriptLogWriter writer = _chatLogWriter ??= new ChatTranscriptLogWriter(log); + string? closedName = log.CurrentName; + + writer.Detach(); + bool closed = log.Close(); + + if (string.IsNullOrWhiteSpace(name)) + return new ChatLogResult(Opened: false, closed, string.Empty, closedName); + + bool opened = log.Open(name, out string resolved); + if (opened) + writer.Attach(_domain.Communication.Chat); + + return new ChatLogResult(opened, closed, resolved, closedName); + } + private LiveSessionResetBindings CreateResetBindings( IRuntimeGenerationResetHost resetHost) => new() { @@ -582,6 +632,7 @@ internal sealed class LiveSessionRuntimeFactory _ui.RetailUi?.ShowConfirmation(message, completed), Suicide: session.SendSuicide, ClearChat: _ => _domain.Communication.Chat.Clear(), + SetChatLogFile: SetChatLogFile, SaveUi: name => _ui.RetailUi?.SaveNamedLayout(name), LoadUi: name => _ui.RetailUi?.RestoreNamedLayout(name), SaveAutoUi: () => _ui.RetailUi?.SaveLayout(), diff --git a/src/AcDream.App/UI/ChatTranscriptLogWriter.cs b/src/AcDream.App/UI/ChatTranscriptLogWriter.cs new file mode 100644 index 00000000..438fe094 --- /dev/null +++ b/src/AcDream.App/UI/ChatTranscriptLogWriter.cs @@ -0,0 +1,73 @@ +using System; +using AcDream.Core.Chat; +using AcDream.UI.Abstractions.Panels.Chat; + +namespace AcDream.App.UI; + +/// +/// Copies the chat transcript into retail's @log file. +/// +/// +/// +/// Retail's log write sits INSIDE ClientSystem::AddTextToScroll +/// (fprintf(s_pLogFile, "%ls%ls\n", …) @0x00563E5B) — downstream of +/// composition and upstream of glyph layout. So the log records the finished +/// display line, timestamp included, and does not re-derive one. +/// is acdream's equivalent single fan-in. +/// +/// +/// Attaching happens on OPEN rather than at startup, which is what retail's +/// own help promises: "All the information that appears in your chat window +/// AFTER you type this command will be copied into a text file." +/// +/// +public sealed class ChatTranscriptLogWriter +{ + private readonly ChatSessionLog _log; + private ChatLog? _source; + + public ChatTranscriptLogWriter(ChatSessionLog log) + => _log = log ?? throw new ArgumentNullException(nameof(log)); + + /// + /// Starts copying , detaching from whatever was + /// attached before. Re-attaching to the same transcript does not double + /// up. + /// + public void Attach(ChatLog source) + { + ArgumentNullException.ThrowIfNull(source); + Detach(); + _source = source; + source.EntryAppended += Write; + } + + /// + /// Stops copying. Detaches from the instance actually attached to, not + /// from whatever is current — a transcript replaced mid-log must not leave + /// a handler behind on the old one. + /// + public void Detach() + { + if (_source is null) + return; + + _source.EntryAppended -= Write; + _source = null; + } + + private void Write(ChatEntry entry) + { + ChatLog? source = _source; + if (source is null) + return; + + // The SAME gate the window uses, so a log never disagrees with the + // transcript it is a copy of. + bool stamped = source.DisplayTimestampsSource?.Invoke() == true; + + _log.Write( + stamped ? ChatLog.FormatTimestampPrefix(entry.Received) : null, + ChatVM.FormatEntry(entry)); + } +} diff --git a/src/AcDream.App/UI/ClientCommandController.cs b/src/AcDream.App/UI/ClientCommandController.cs index a4cf2145..0b34596e 100644 --- a/src/AcDream.App/UI/ClientCommandController.cs +++ b/src/AcDream.App/UI/ClientCommandController.cs @@ -1,4 +1,5 @@ using System.Globalization; +using AcDream.Core.Chat; using AcDream.Core.Physics; using AcDream.Core.Ui; using AcDream.Core.Social; @@ -34,6 +35,7 @@ public sealed class ClientCommandController Action> ShowConfirmation, Action Suicide, Action ClearChat, + Func SetChatLogFile, Action SaveUi, Action LoadUi, Action SaveAutoUi, @@ -189,6 +191,9 @@ public sealed class ClientCommandController _bindings.ClearChat(FirstArgument(command.Arguments) .Equals("all", StringComparison.OrdinalIgnoreCase)); break; + case ClientCommandId.ChatLogFile: + ExecuteChatLogFile(command.Arguments); + break; case ClientCommandId.SaveUi: ExecuteUiProfile(command.Arguments, save: true); break; @@ -407,6 +412,38 @@ public sealed class ClientCommandController return false; } + /// + /// Retail's @log (ClientCommunicationSystem::DoSetOutput + /// @0x0057E4F0). One verb does both jobs: a filename opens a log, no + /// argument closes the open one. All four replies are retail's own + /// strings, byte-decoded from the paired binary because Binary Ninja + /// truncates its previews at ~33 characters. + /// + private void ExecuteChatLogFile(string arguments) + { + // Retail JoinArgs the remainder, so the name may contain spaces. + string name = arguments.Trim(); + ChatLogResult result = _bindings.SetChatLogFile(name); + + // CloseLogFile announces itself wherever it is called from, which + // includes the open path — starting a second log tells you the first + // one ended. + if (result.Closed) + _bindings.ShowSystemMessage($"Chat log {result.ClosedName} closed."); + + if (name.Length == 0) + { + _bindings.ShowSystemMessage(result.Closed + ? "Chat output now directed only to the screen." + : "Please specify a file to append chat messages to."); + return; + } + + _bindings.ShowSystemMessage(result.Opened + ? $"Copying chat to {result.Name}. Run command again with no arguments to turn off logging." + : $"Failed to redirect to file {result.Name}!"); + } + private void ExecuteAway(string arguments) { string first = FirstArgument(arguments); diff --git a/src/AcDream.Core/Chat/ChatSessionLog.cs b/src/AcDream.Core/Chat/ChatSessionLog.cs new file mode 100644 index 00000000..94514621 --- /dev/null +++ b/src/AcDream.Core/Chat/ChatSessionLog.cs @@ -0,0 +1,184 @@ +using System; +using System.IO; +using System.Text; + +namespace AcDream.Core.Chat; + +/// +/// Retail's @log chat-to-file capture. +/// +/// +/// +/// This is NOT an automatic session transcript. Retail opens a log only when +/// the player asks for one by name — ClientCommunicationSystem::DoSetOutput +/// @0x0057E4F0 takes a filename, StartCopyOutputToFile @0x0057C8A0 +/// does the fopen(name, "a+"), and running the command again with no +/// argument closes it. The file is APPENDED to, never rotated and never +/// truncated, which is what retail's own help promises: "If this file already +/// exists, it will add the additional text to the end of it." +/// +/// +/// Every line goes out as timestamp + text + "\n" +/// (fprintf(s_pLogFile, "%ls%ls\n", …) @0x00563E5B, inside +/// ClientSystem::AddTextToScroll), with the timestamp present only when +/// the DisplayTimeStamps option is on — the log and the chat window +/// share the one stamp rather than deciding separately. +/// +/// +/// File handling only. What a line SAYS is composed upstream, because retail +/// logs the finished display line rather than re-deriving one. +/// +/// +public sealed class ChatSessionLog : IDisposable +{ + private readonly string _baseDirectory; + private StreamWriter? _writer; + + /// + /// Where a bare filename lands. Retail says "your Asheron's Call + /// directory" — its install directory — which acdream cannot use: the + /// launcher replaces the install atomically on update, so a file written + /// there is wiped or blocks the update. The client's own log directory is + /// the equivalent that survives. Rooted paths are still honoured verbatim, + /// as retail's fopen would. + /// + public ChatSessionLog(string baseDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(baseDirectory); + _baseDirectory = baseDirectory; + } + + /// The name the player asked for, or null when nothing is open. + public string? CurrentName { get; private set; } + + public bool IsOpen => _writer is not null; + + /// + /// Retail appends .txt to an extensionless name + /// (PSUtils::get_extension against the empty string, then + /// += ".txt"). A name that already carries ANY extension is left + /// alone — "chat.old" stays "chat.old" rather than becoming "chat.old.txt". + /// + public static string EnsureExtension(string name) + => Path.GetExtension(name).Length == 0 ? name + ".txt" : name; + + /// + /// Opens for append, closing any log already open + /// first — retail's StartCopyOutputToFile calls CloseLogFile + /// before it does anything else. + /// + /// + /// Whether the file opened. Retail reports failure to the player rather + /// than treating it as fatal, so an unwritable path is an ordinary answer + /// here and not an exception. + /// + public bool Open(string name, out string resolvedName) + { + resolvedName = string.Empty; + Close(); + + if (string.IsNullOrWhiteSpace(name)) + return false; + + resolvedName = EnsureExtension(name.Trim()); + + try + { + string path = Path.IsPathRooted(resolvedName) + ? resolvedName + : Path.Combine(_baseDirectory, resolvedName); + + string? directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + _writer = new StreamWriter( + new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite), + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)) + { + // Flushed per line: a chat log's whole point is being readable + // while the client is still running, and a crash must not eat + // the tail that explains it. + AutoFlush = true, + }; + CurrentName = resolvedName; + return true; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException + or ArgumentException or NotSupportedException) + { + _writer = null; + CurrentName = null; + return false; + } + } + + /// Closes the open log, if any. + /// + /// Whether one WAS open. Retail's CloseLogFile returns this and its + /// caller uses it to choose between "closed" and "please specify a file". + /// + public bool Close() + { + if (_writer is null) + return false; + + try + { + _writer.Dispose(); + } + catch (IOException) + { + // The line is already gone; failing to flush a closing file is not + // something the player can act on. + } + + _writer = null; + CurrentName = null; + return true; + } + + /// + /// Writes one transcript line. A no-op when no log is open, so the caller + /// can hand every line over unconditionally the way retail does. + /// + public void Write(string? timestampPrefix, string? text) + { + StreamWriter? writer = _writer; + if (writer is null) + return; + + try + { + writer.Write(timestampPrefix); + writer.Write(text); + writer.Write('\n'); + } + catch (IOException) + { + // A vanished drive or a full disk stops the log; it must not stop + // chat. Retail ignores fprintf's return except to warn about very + // long lines. + } + } + + public void Dispose() => Close(); +} + +/// +/// What one @log invocation did, so the caller can print retail's +/// replies without reaching into the file handle. +/// +/// A new log was opened. +/// +/// A log that WAS open got closed. True both when closing is the whole point +/// and when opening a second log displaced the first — retail's +/// StartCopyOutputToFile closes before it opens, and announces it. +/// +/// The resolved name of the new log, extension included. +/// The name of the log that was closed, if any. +public readonly record struct ChatLogResult( + bool Opened, + bool Closed, + string Name, + string? ClosedName); diff --git a/src/AcDream.Runtime/Chat/ClientCommandId.cs b/src/AcDream.Runtime/Chat/ClientCommandId.cs index 42aee416..e6ca5f7b 100644 --- a/src/AcDream.Runtime/Chat/ClientCommandId.cs +++ b/src/AcDream.Runtime/Chat/ClientCommandId.cs @@ -24,6 +24,12 @@ public enum ClientCommandId ShowLastCorpseLocation, Die, ClearChat, + + /// + /// Retail's @log: start or stop copying chat to a file. + /// ClientCommunicationSystem::DoSetOutput @0x0057E4F0. + /// + ChatLogFile, SaveUi, LoadUi, SaveAutoUi, diff --git a/src/AcDream.Runtime/Chat/RetailClientCommandCatalog.cs b/src/AcDream.Runtime/Chat/RetailClientCommandCatalog.cs index f819526d..ed534a74 100644 --- a/src/AcDream.Runtime/Chat/RetailClientCommandCatalog.cs +++ b/src/AcDream.Runtime/Chat/RetailClientCommandCatalog.cs @@ -145,6 +145,15 @@ public static class RetailClientCommandCatalog "/clear [all]", "/clear [all] - Clears the current chat window, or every chat window."); + /// + /// Retail takes the whole remainder as the filename — DoSetOutput + /// calls JoinArgs first, so a name with spaces in it works. + /// + private static readonly Definition ChatLogFile = AnyArguments( + ClientCommandId.ChatLogFile, + "/log [filename]", + "/log [filename] - Echoes chat text to a logfile, or stops if already logging."); + private static readonly Definition SaveUi = AnyArguments( ClientCommandId.SaveUi, "/saveui [filename]", @@ -497,6 +506,7 @@ public static class RetailClientCommandCatalog ["cor"] = Corpse, ["die"] = Die, ["clear"] = Clear, + ["log"] = ChatLogFile, ["saveui"] = SaveUi, ["loadui"] = LoadUi, ["saveautoui"] = SaveAutoUi, diff --git a/src/AcDream.Runtime/Chat/RetailCommandHelpTable.cs b/src/AcDream.Runtime/Chat/RetailCommandHelpTable.cs index 4e07b679..4e7054d3 100644 --- a/src/AcDream.Runtime/Chat/RetailCommandHelpTable.cs +++ b/src/AcDream.Runtime/Chat/RetailCommandHelpTable.cs @@ -949,6 +949,12 @@ public static class RetailCommandHelpTable private static readonly FrozenDictionary CatalogVerbDetailByVerb = new Dictionary(StringComparer.OrdinalIgnoreCase) { + // CT-B4 (2026-08-21): "log" became a catalog verb, so its retail + // help has to be reachable through the CATALOG path too — the + // catalog's own one-line summary is acdream-authored, and showing + // that in place of retail's text is exactly what this table exists + // to prevent. + ["log"] = Log, ["lifestone"] = LifestoneDetail, ["lif"] = LifestoneDetail, ["ls"] = LifestoneDetail, diff --git a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs index 80a656e2..82b2db72 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs @@ -837,6 +837,7 @@ public sealed class LiveSessionCommandRouterTests ShowConfirmation: (_, _) => { }, Suicide: () => { }, ClearChat: _ => { }, + SetChatLogFile: _ => default, SaveUi: _ => { }, LoadUi: _ => { }, SaveAutoUi: () => { }, diff --git a/tests/AcDream.App.Tests/UI/ChatTranscriptLogWriterTests.cs b/tests/AcDream.App.Tests/UI/ChatTranscriptLogWriterTests.cs new file mode 100644 index 00000000..ffe5258b --- /dev/null +++ b/tests/AcDream.App.Tests/UI/ChatTranscriptLogWriterTests.cs @@ -0,0 +1,121 @@ +using System; +using System.IO; +using AcDream.App.UI; +using AcDream.Core.Chat; + +namespace AcDream.App.Tests.UI; + +/// +/// CT-B4: what actually reaches the @log file. +/// +public sealed class ChatTranscriptLogWriterTests : IDisposable +{ + private readonly string _directory = + Path.Combine(Path.GetTempPath(), "acdream-logwriter-" + Guid.NewGuid().ToString("N")); + + public void Dispose() + { + try { Directory.Delete(_directory, recursive: true); } + catch (IOException) { /* nothing left to say */ } + } + + private string Read() => File.ReadAllText(Path.Combine(_directory, "session.txt")); + + [Fact] + public void TheComposedDisplayLineIsLoggedRatherThanTheRawMessage() + { + // The entry's own Text is just 'hello' — the speaker and the quotes + // are composition. Retail's log write sits downstream of that + // (fprintf inside AddTextToScroll), so logging entry.Text would give + // a file full of bare fragments with no idea who said them. + using var log = new ChatSessionLog(_directory); + var transcript = new ChatLog(); + var writer = new ChatTranscriptLogWriter(log); + + log.Open("session", out _); + writer.Attach(transcript); + transcript.OnLocalSpeech("Dww", "hello", 0x02u, false, 0u); + log.Close(); + + Assert.Equal("Dww says, \"hello\"\n", Read()); + } + + [Fact] + public void TheTimestampFollowsTheSameOptionTheWindowUses() + { + // Retail computes the stamp ONCE and hands the same string to the + // window and to the file, so the two can never disagree. + using var log = new ChatSessionLog(_directory); + bool stamps = false; + var transcript = new ChatLog { DisplayTimestampsSource = () => stamps }; + var writer = new ChatTranscriptLogWriter(log); + + log.Open("session", out _); + writer.Attach(transcript); + transcript.OnLocalSpeech("Dww", "before", 0x02u, false, 0u); + stamps = true; + transcript.OnLocalSpeech("Dww", "after", 0x02u, false, 0u); + log.Close(); + + string[] lines = Read().Split('\n', StringSplitOptions.RemoveEmptyEntries); + Assert.Equal("Dww says, \"before\"", lines[0]); + Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} Dww says, ""after""$", lines[1]); + } + + [Fact] + public void NothingIsLoggedBeforeAttachOrAfterDetach() + { + // Retail's help is explicit that logging starts when you type the + // command: only what appears AFTER it is copied. + using var log = new ChatSessionLog(_directory); + var transcript = new ChatLog(); + var writer = new ChatTranscriptLogWriter(log); + + log.Open("session", out _); + transcript.OnLocalSpeech("Dww", "before attach", 0x02u, false, 0u); + writer.Attach(transcript); + transcript.OnLocalSpeech("Dww", "during", 0x02u, false, 0u); + writer.Detach(); + transcript.OnLocalSpeech("Dww", "after detach", 0x02u, false, 0u); + log.Close(); + + Assert.Equal("Dww says, \"during\"\n", Read()); + } + + [Fact] + public void AttachingTwiceDoesNotWriteEveryLineTwice() + { + using var log = new ChatSessionLog(_directory); + var transcript = new ChatLog(); + var writer = new ChatTranscriptLogWriter(log); + + log.Open("session", out _); + writer.Attach(transcript); + writer.Attach(transcript); + transcript.OnLocalSpeech("Dww", "once", 0x02u, false, 0u); + log.Close(); + + Assert.Equal("Dww says, \"once\"\n", Read()); + } + + [Fact] + public void DetachReleasesTheTranscriptItActuallyAttachedTo() + { + // A session teardown replaces the transcript. Detaching from the + // CURRENT one would leave a live handler on the old one, which then + // keeps writing into a file the player believes is closed. + using var log = new ChatSessionLog(_directory); + var first = new ChatLog(); + var second = new ChatLog(); + var writer = new ChatTranscriptLogWriter(log); + + log.Open("session", out _); + writer.Attach(first); + writer.Attach(second); // switches transcripts + first.OnLocalSpeech("Dww", "stale", 0x02u, false, 0u); + second.OnLocalSpeech("Dww", "live", 0x02u, false, 0u); + log.Close(); + + Assert.Equal("Dww says, \"live\"\n", Read()); + } +} diff --git a/tests/AcDream.App.Tests/UI/ClientCommandControllerTests.cs b/tests/AcDream.App.Tests/UI/ClientCommandControllerTests.cs index 92febc2a..5d5a85a7 100644 --- a/tests/AcDream.App.Tests/UI/ClientCommandControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ClientCommandControllerTests.cs @@ -1,4 +1,5 @@ using AcDream.App.UI; +using AcDream.Core.Chat; using AcDream.Core.Physics; using AcDream.Core.Social; using AcDream.UI.Abstractions; @@ -432,6 +433,125 @@ public sealed class ClientCommandControllerTests Assert.Throws(() => controller.Execute(command)); } + // ── CT-B4: retail's @log ──────────────────────────────────────────── + + [Fact] + public void Log_WithAName_ReportsWhereChatIsGoingAndHowToStop() + { + // Retail's reply, byte-decoded from the paired binary — Binary Ninja + // truncates it at "Copying chat to %s. Run command…". Note the TWO + // spaces after the period; they are retail's. + var messages = new List(); + var calls = new List(); + ClientCommandController ctrl = NewController( + calls, messages: messages, + chatLog: name => new ChatLogResult(true, false, name, null)); + + ctrl.Execute(new ExecuteClientCommandCmd( + ClientCommandId.ChatLogFile, "aclog.txt")); + + Assert.Contains("log:aclog.txt", calls); + Assert.Equal( + "Copying chat to aclog.txt. Run command again with no arguments " + + "to turn off logging.", + Assert.Single(messages)); + } + + [Fact] + public void Log_WhenTheFileCannotBeOpened_SaysSoRatherThanClaimingSuccess() + { + var messages = new List(); + ClientCommandController ctrl = NewController( + messages: messages, + chatLog: name => new ChatLogResult(false, false, name, null)); + + ctrl.Execute(new ExecuteClientCommandCmd( + ClientCommandId.ChatLogFile, "C:/nope/x.txt")); + + Assert.Equal( + "Failed to redirect to file C:/nope/x.txt!", + Assert.Single(messages)); + } + + [Fact] + public void Log_WithNoArgument_ClosesTheOpenLogAndSaysBothLines() + { + // CloseLogFile announces the file, then DoSetOutput announces the + // redirect. Two lines, in that order. + var messages = new List(); + ClientCommandController ctrl = NewController( + messages: messages, + chatLog: _ => new ChatLogResult(false, true, string.Empty, "aclog.txt")); + + ctrl.Execute(new ExecuteClientCommandCmd( + ClientCommandId.ChatLogFile, "")); + + Assert.Equal( + ["Chat log aclog.txt closed.", "Chat output now directed only to the screen."], + messages); + } + + [Fact] + public void Log_WithNoArgumentAndNothingOpen_AsksForAFileName() + { + // The same verb with the same arguments says something DIFFERENT + // depending on whether a log was running — retail branches on + // CloseLogFile's return value, not on the arguments. + var messages = new List(); + ClientCommandController ctrl = NewController( + messages: messages, + chatLog: _ => new ChatLogResult(false, false, string.Empty, null)); + + ctrl.Execute(new ExecuteClientCommandCmd( + ClientCommandId.ChatLogFile, "")); + + Assert.Equal( + "Please specify a file to append chat messages to.", + Assert.Single(messages)); + } + + [Fact] + public void Log_StartingASecondLogAnnouncesThatTheFirstEnded() + { + var messages = new List(); + ClientCommandController ctrl = NewController( + messages: messages, + chatLog: name => new ChatLogResult(true, true, name, "old.txt")); + + ctrl.Execute(new ExecuteClientCommandCmd( + ClientCommandId.ChatLogFile, "new.txt")); + + Assert.Equal("Chat log old.txt closed.", messages[0]); + Assert.StartsWith("Copying chat to new.txt.", messages[1]); + } + + [Fact] + public void Log_TakesTheWholeRemainderSoASpacedNameSurvives() + { + // Retail JoinArgs the arguments before using them as a filename. + var calls = new List(); + ClientCommandController ctrl = NewController( + calls, + chatLog: name => new ChatLogResult(true, false, name, null)); + + ctrl.Execute(new ExecuteClientCommandCmd( + ClientCommandId.ChatLogFile, "my chat log.txt")); + + Assert.Contains("log:my chat log.txt", calls); + } + + [Fact] + public void Log_ResolvesFromTheCatalogWithItsWholeRemainderAsTheArgument() + { + // The verb had a help entry since CH4 but no catalog entry, so /log + // printed help and did nothing. This pins the registration. + Assert.True(RetailClientCommandCatalog.TryMatch( + "/log my chat log.txt", out RetailClientCommandCatalog.Match match)); + + Assert.Equal(ClientCommandId.ChatLogFile, match.Command); + Assert.Equal("my chat log.txt", match.Arguments); + } + private static ClientCommandController NewController( List? calls = null, List? errors = null, @@ -448,7 +568,8 @@ public sealed class ClientCommandControllerTests // ShowConfirmation calls (e.g. house-abandon's two-stage prompt). // Defaults to "always accept" so every pre-existing single-stage // test (Die, etc.) keeps its original behavior unchanged. - Queue? confirmationResponses = null) + Queue? confirmationResponses = null, + Func? chatLog = null) { calls ??= []; errors ??= []; @@ -483,6 +604,16 @@ public sealed class ClientCommandControllerTests }, () => calls.Add("suicide"), all => calls.Add("clear:" + all), + name => + { + calls.Add("log:" + name); + return chatLog?.Invoke(name) + ?? new AcDream.Core.Chat.ChatLogResult( + Opened: name.Length > 0, + Closed: name.Length == 0, + Name: name, + ClosedName: "old.txt"); + }, name => calls.Add("saveui:" + name), name => calls.Add("loadui:" + name), () => calls.Add("saveautoui"), diff --git a/tests/AcDream.Core.Tests/Chat/ChatSessionLogTests.cs b/tests/AcDream.Core.Tests/Chat/ChatSessionLogTests.cs new file mode 100644 index 00000000..ae343d0d --- /dev/null +++ b/tests/AcDream.Core.Tests/Chat/ChatSessionLogTests.cs @@ -0,0 +1,142 @@ +using System; +using System.IO; +using AcDream.Core.Chat; + +namespace AcDream.Core.Tests.Chat; + +/// +/// Retail's @log file behaviour: append, never rotate, never truncate. +/// +public sealed class ChatSessionLogTests : IDisposable +{ + private readonly string _directory = + Path.Combine(Path.GetTempPath(), "acdream-chatlog-" + Guid.NewGuid().ToString("N")); + + public void Dispose() + { + try { Directory.Delete(_directory, recursive: true); } + catch (IOException) { /* the test already told us what it needed to */ } + } + + [Theory] + [InlineData("aclog", "aclog.txt")] + [InlineData("aclog.txt", "aclog.txt")] + [InlineData("aclog.log", "aclog.log")] + // A name that already carries ANY extension is left alone; retail tests + // the extension for emptiness, not for ".txt". + [InlineData("chat.old", "chat.old")] + public void AnExtensionlessNameGainsDotTxt(string given, string expected) + => Assert.Equal(expected, ChatSessionLog.EnsureExtension(given)); + + [Fact] + public void LinesLandInTheFileWithTheirTimestampAndANewline() + { + using var log = new ChatSessionLog(_directory); + + Assert.True(log.Open("session", out string resolved)); + Assert.Equal("session.txt", resolved); + log.Write("13:05:09 ", "Dww tells you, \"hello\""); + log.Write(null, "Welcome to Dereth."); + log.Close(); + + Assert.Equal( + "13:05:09 Dww tells you, \"hello\"\nWelcome to Dereth.\n", + File.ReadAllText(Path.Combine(_directory, "session.txt"))); + } + + [Fact] + public void ReopeningTheSameNameAppendsRatherThanTruncating() + { + // Retail's own help promises this: "If this file already exists, it + // will add the additional text to the end of it." Truncating would + // destroy the previous session's log the moment you start a new one. + using var log = new ChatSessionLog(_directory); + + log.Open("session", out _); + log.Write(null, "first"); + log.Close(); + + log.Open("session", out _); + log.Write(null, "second"); + log.Close(); + + Assert.Equal( + "first\nsecond\n", + File.ReadAllText(Path.Combine(_directory, "session.txt"))); + } + + [Fact] + public void OpeningASecondLogClosesTheFirst() + { + // StartCopyOutputToFile calls CloseLogFile before it does anything + // else, so two files can never be open at once. + using var log = new ChatSessionLog(_directory); + + log.Open("one", out _); + log.Write(null, "to one"); + Assert.True(log.Open("two", out _)); + log.Write(null, "to two"); + log.Close(); + + Assert.Equal("to one\n", File.ReadAllText(Path.Combine(_directory, "one.txt"))); + Assert.Equal("to two\n", File.ReadAllText(Path.Combine(_directory, "two.txt"))); + } + + [Fact] + public void CloseReportsWhetherOneWasOpen() + { + // The caller picks between two different retail replies on this, so + // it is load-bearing rather than informational. + using var log = new ChatSessionLog(_directory); + + Assert.False(log.Close()); + log.Open("session", out _); + Assert.True(log.Close()); + Assert.False(log.Close()); + } + + [Fact] + public void WritingWithNoLogOpenIsANoOp() + { + // The caller hands over every transcript line unconditionally, the way + // retail does, so the closed case has to be silent rather than throw. + using var log = new ChatSessionLog(_directory); + + log.Write("13:05:09 ", "nobody is listening"); + + Assert.False(log.IsOpen); + Assert.Null(log.CurrentName); + Assert.False(Directory.Exists(_directory)); + } + + [Fact] + public void AnUnopenableNameReportsFailureInsteadOfThrowing() + { + // Retail tells the player "Failed to redirect to file %s!" rather than + // dying, so a bad name is an ordinary answer here. + using var log = new ChatSessionLog(_directory); + + // A directory cannot be opened as a file. + Directory.CreateDirectory(Path.Combine(_directory, "taken.txt")); + + Assert.False(log.Open("taken.txt", out string resolved)); + Assert.Equal("taken.txt", resolved); + Assert.False(log.IsOpen); + } + + [Fact] + public void ARootedNameIsHonouredVerbatim() + { + // Retail's fopen takes the string as given; a player who types a full + // path means it. + using var log = new ChatSessionLog(_directory); + string rooted = Path.Combine(_directory, "nested", "elsewhere.txt"); + + Assert.True(log.Open(rooted, out string resolved)); + Assert.Equal(rooted, resolved); + log.Write(null, "here"); + log.Close(); + + Assert.Equal("here\n", File.ReadAllText(rooted)); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandHelpTableTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandHelpTableTests.cs index 62b8ddf1..bdea46ed 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandHelpTableTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandHelpTableTests.cs @@ -415,6 +415,19 @@ public sealed class RetailCommandHelpTableTests entries[0].LogTextType); } + [Fact] + public void HelpForLog_StillReturnsRetailsOwnTextNowThatItIsACatalogVerb() + { + // Registering a verb in the catalog CHANGES which help table answers + // for it. "log" had retail's real help under the passthrough table for + // the whole of CH4; adding it to the catalog without moving that text + // would have quietly replaced it with acdream's own one-line summary. + Assert.True( + RetailCommandHelpTable.TryGetCatalogVerbDetailText("log", out string detail)); + Assert.Equal(RetailCommandHelpTable.Log, detail); + Assert.StartsWith("@log - Echoes chat text to a logfile.", detail); + } + [Fact] public void CatalogLeafVerbCoverage_ExtractedVsConfirmedNullVsUnverified_MatchesConsolidatedReviewCount() { @@ -475,7 +488,9 @@ public sealed class RetailCommandHelpTableTests // unverified to extracted (its real live-construction is now // ported -- see RetailCommandHelpTable.MessageTypesDetail) -- // 43/4/0, zero remaining unverified leaf verbs. - Assert.Equal(43, extractedCount); + // CT-B4 (2026-08-21): "log" joined the catalog, bringing its already- + // extracted retail Detail text with it -- 44/4/0. + Assert.Equal(44, extractedCount); Assert.Equal(4, confirmedNullCount); Assert.Equal(0, unverifiedCount); } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandRegistryConformanceTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandRegistryConformanceTests.cs index 3ca66f74..df4142b3 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandRegistryConformanceTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandRegistryConformanceTests.cs @@ -73,7 +73,12 @@ public sealed class RetailCommandRegistryConformanceTests new(Status.Implemented, "on"), new(Status.Implemented, "off"), new(Status.Implemented, "title"), - new(Status.ServerPassthrough, "log"), // TS-69 + // CT-B4 (2026-08-21): "log" was never a server passthrough. Retail + // handles it entirely client-side — DoSetOutput @0x0057E4F0 opens a + // file, and nothing goes on the wire. It was classified here as a + // passthrough because it had a help entry and no catalog entry, which + // is the shape an unimplemented client command has too. + new(Status.Implemented, "log"), new(Status.Implemented, "clear"), new(Status.Implemented, "filter"), new(Status.Implemented, "unfilter"), @@ -211,8 +216,12 @@ public sealed class RetailCommandRegistryConformanceTests public void Registry_StatusCountsMatchTheAuditedTotals() { Assert.Equal(9, Registry.Where(e => e.Status == Status.HelpOnly).Sum(e => e.Verbs.Length)); - Assert.Equal(5, Registry.Where(e => e.Status == Status.ServerPassthrough).Sum(e => e.Verbs.Length)); - Assert.Equal(138, Registry.Where(e => e.Status == Status.Implemented).Sum(e => e.Verbs.Length)); + // CT-B4 moved "log" from ServerPassthrough to Implemented, so these + // two totals shift by one against the CH4 audit. The 152 verb total + // is unchanged, which is what NoDuplicateVerbsAcrossEntries and the + // section counts protect. + Assert.Equal(4, Registry.Where(e => e.Status == Status.ServerPassthrough).Sum(e => e.Verbs.Length)); + Assert.Equal(139, Registry.Where(e => e.Status == Status.Implemented).Sum(e => e.Verbs.Length)); } [Fact] From 730662f81949074887f27360f1d0e242f7755c38 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 14:36:04 +0200 Subject: [PATCH 27/43] docs(quest): plan Campaign QT, and add the layout search that found the panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H.3's roadmap line ("122 EmoteType x 39 Trigger mini-VM") describes the SERVER's job. The retail client never stores a quest flag, never evaluates an emote, and is never told a flag changed — so most of H.3 was never client work at all. Measuring what we already have narrows the remaining scope to one thing: the contract tracker, the only structured view of quest state a client ever gets. The user confirmed NPC dialogue works live. LayoutDump could only dump a layout you already knew the id of, but the decomp hands you a CLASS id with no layout attached (UIElement::RegisterElementClass), so the gap between the two was crossed by guessing. --find closes it, and it searches the element's TYPE as well as its id because registration keys on Type — searching only the id finds a real element with the same number and quietly answers the wrong question, which is exactly what it did on the first run here. The plan records the wire layout, the panel's authored children, and FillProgressString in full, including the three things a reimplementation would get wrong: TimeWhenDone is never read, the countdown anchor is not on the wire, and DescriptionProgress is a printf format rather than a string. Co-Authored-By: Claude Opus 5 --- .../2026-08-21-contract-tracker-campaign.md | 124 ++++++++++++++++++ tools/LayoutDump/Program.cs | 80 +++++++++++ 2 files changed, 204 insertions(+) create mode 100644 docs/plans/2026-08-21-contract-tracker-campaign.md diff --git a/docs/plans/2026-08-21-contract-tracker-campaign.md b/docs/plans/2026-08-21-contract-tracker-campaign.md new file mode 100644 index 00000000..a02b7a12 --- /dev/null +++ b/docs/plans/2026-08-21-contract-tracker-campaign.md @@ -0,0 +1,124 @@ +# Campaign QT — the contract tracker (H.3's client half) + +**Status:** ACTIVE 2026-08-21. + +**Why now.** M4's demo scenario is "talk to an NPC, accept a quest, ... complete +the quest." Everything in that sentence works today EXCEPT the player's ability +to see what they have accepted. NPC dialogue, emote text, soul emotes, tells and +the quest-failure strings all render; Campaign CT (closed 2026-08-21) added the +`` markup those dialog lines carry. What is missing is the +only STRUCTURED view of quest state a retail client ever gets. + +**What H.3 is not.** The roadmap line reads "122 EmoteType × 39 Trigger +mini-VM", which describes the SERVER's job. Per `r10-quest-dialogs.md` §1.3 the +retail client never stores a quest flag, never evaluates an emote, and is never +told a flag changed. It learns about quests three ways: dialog strings the +server already formatted, generic error toasts, and the contract tracker. Two of +the three ship. So H.3's remaining client scope is this campaign, and the emote +VM is explicitly out of it. + +## Measured ground truth + +### The panel + +`LayoutDump --find 0x1000004B` (the `UIElement::RegisterElementClass` id from +`gmContractsUI::Register @0x00499C80` — registration keys on the element's +**Type**, not its id) finds the class in six layouts. `0x21000069` holds it as a +standalone 300x500 root (`0x100005CD`); the rest embed it at 300x575 inside +window chrome. + +Authored children of `0x100005CD`: + +| Element | Type | Rect | Reading | +|---|---|---|---| +| `0x100005CE` | 1 | 8,8 80x18 | header button | +| `0x100005D6` | 1 | 160,8 80x18 | header button | +| `0x100005CF` | 5 | 8,30 270x298 | the contract list | +| `0x100005D0` | 11 | 278,30 16x298 | its scrollbar | +| `0x100005D8`/`0x100005DF` | 12 | y=332 | label / value | +| `0x100005D9`/`0x100005E0` | 12 | y=352 | label / value | +| `0x100005DA`/`0x100005E1` | 12 | y=372 | label / value | +| `0x100005DB`/`0x100005E2` | 12 | y=392 | label / value | +| `0x100005DE` | 12 | 8,418 270x52 | description block | +| `0x100005DD`, `0x100005E3`, `0x100005DC` | 12/12/1 | y=468 | button row | + +### The wire + +Both opcodes are already NAMED in `GameEventType.cs` and nothing parses them — +the bytes arrive and are dropped. + +`0x0315 SendClientContractTracker` — one tracker plus two flags: + +``` +uint32 Version +uint32 ContractId +uint32 Stage +double TimeWhenDone +double TimeWhenRepeats +uint32 DeleteContract (bool widened) +uint32 SetAsDisplayContract (bool widened) +``` + +`0x0314 SendClientContractTrackerTable` — a full replacement, as a packable +hash table (`PackableHashTable` in the decomp +at `0x00497C10`): the familiar `u16 count` / `u16 numBuckets` header, then +`u32 key` + the 28-byte tracker per entry. NO trailing flags on this path. + +Source: `ContractTrackerExtensions.Write`, `GameEventSendClientContractTracker`, +`ContractManager.Write` in ACE; cross-checked against the retail decomp's own +`PackableHashTable` instantiations. + +`ContractStage`: `1` Available, `2` InProgress, `3` DoneOrPendingRepeat, +`4 + n` ProgressCounter with n steps done. + +### `gmContractsUI::FillProgressString @0x00498DE0` — the one real algorithm + +Recovered whole. The x87 compares are the standard `fcom` + `sahf` pattern; +`(status & 0x41) != 0` tests C0|C3, i.e. **<= 0**. + +``` +stage 1 -> "Available" +stage 2 -> "In Progress" +stage 3: + if TimeWhenRepeats <= 0 + -> QuestflagRepeatTime empty ? "Done" : "Available" + remaining = TimeWhenRepeats - (now - timeOfServerUpdate) + if remaining <= 0 -> "Available" + else -> "Done (" + DeltaTimeToString(remaining) + " to Repeat)" +stage >= 4: + if DescriptionProgress empty -> "In Progress" + else -> sprintf(DescriptionProgress, stage - 4) +``` + +Three things a reimplementation would get wrong: + +1. **`TimeWhenDone` is never read.** Only `TimeWhenRepeats` drives the text. +2. **`timeOfServerUpdate` is not on the wire.** The client stamps arrival and + counts down from its own clock, so the countdown has to be anchored at parse + time, not recomputed from the server value each frame. +3. **`DescriptionProgress` is a printf format** taking one integer, `stage - 4`. + It is not a literal string. + +## Slices + +- **QT1 — wire.** Typed records + parsers for `0x0314`/`0x0315`, arrival stamp + included. Pure; no UI, no state ownership. +- **QT2 — dat.** Read `ContractTable`/`Contract` (name, description, progress + description, NPC names, the three positions). Nothing reads it today; the + only reference in the tree counts them in a CLI diagnostic. +- **QT3 — state.** `RuntimeContractState` as a session-scoped J4-style owner: + full replace, single add/update, delete, and the display-contract selection. + Clears at generation reset. +- **QT4 — the progress string.** Port `FillProgressString` + the retail + `DeltaTimeToString` it calls. Table-driven tests over every stage arm. +- **QT5 — the panel.** Mount `0x21000069` by the OP3/FA recipe; list, scrollbar, + the four label/value rows, description, buttons. +- **QT6 — open/close.** Whatever raises it in retail, plus the plugin-visible + read surface from `r10-quest-dialogs.md` §11.6. + +## Definition of done + +1. Accepting a quest against live ACE shows it in the panel; completing it + updates the stage; a repeatable one shows its countdown. +2. Every ported algorithm cites its retail address. +3. Every slice has a test that would catch its regression. diff --git a/tools/LayoutDump/Program.cs b/tools/LayoutDump/Program.cs index ac6336dd..9f819ed6 100644 --- a/tools/LayoutDump/Program.cs +++ b/tools/LayoutDump/Program.cs @@ -18,6 +18,7 @@ using SysEnv = System.Environment; if (args.Length == 0) { Console.WriteLine("usage: LayoutDump [rootElementId] [--states]"); + Console.WriteLine(" LayoutDump --find "); return 1; } @@ -34,6 +35,85 @@ string datDir = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR") using var dats = new DatCollection(datDir, DatAccessType.Read); using var adapter = new DatCollectionAdapter(dats); +int findAt = Array.IndexOf(args, "--find"); +if (findAt >= 0) +{ + // "Which layout owns this element?" -- the question every panel port + // starts with, and the one this tool could not answer. Retail registers a + // panel class against an ELEMENT id (UIElement::RegisterElementClass), so + // the decomp hands you an id with no layout attached to it; without a scan + // the only way across that gap is guessing at 0x21xxxxxx ids. + uint wantedElement = findAt + 1 < args.Length + ? Convert.ToUInt32(args[findAt + 1], 16) + : 0u; + if (wantedElement == 0) + { + Console.WriteLine("--find needs an element id"); + return 1; + } + + int scanned = 0; + int hits = 0; + foreach (uint layoutId in dats.GetAllIdsOfType().OrderBy(i => i)) + { + scanned++; + ElementInfo? candidate; + try + { + candidate = LayoutImporter.ImportInfos(adapter, layoutId); + } + catch (Exception e) + { + // A layout this importer cannot read is a finding, not a stop -- + // the whole point is to sweep every one of them. + Console.WriteLine($" layout 0x{layoutId:X8}: FAILED TO IMPORT ({e.GetType().Name})"); + continue; + } + + if (candidate is null) + continue; + + if (FindElement(candidate, wantedElement, out string path)) + { + hits++; + Console.WriteLine($"layout 0x{layoutId:X8} {path}"); + } + } + + Console.WriteLine(); + Console.WriteLine($"element 0x{wantedElement:X8}: {hits} hit(s) across {scanned} layouts"); + return hits > 0 ? 0 : 2; + + // Matches an element's ID or its TYPE. Retail's + // UIElement::RegisterElementClass keys a panel class on the TYPE field + // (0xC = Text, 0x19 = WaitDialog, 0x1000004B = gmContractsUI), so a class + // id out of the decomp is a type; an id out of a layout dump is an id. + // Searching only one of them silently finds the wrong element, because + // the two share a number space. + static bool FindElement(ElementInfo e, uint wanted, out string path) + { + if (e.Id == wanted || (uint)e.Type == wanted) + { + string how = e.Id == wanted ? "id" : "TYPE"; + path = $"0x{e.Id:X8} (match on {how}; type 0x{e.Type:X}, " + + $"{e.Width}x{e.Height} at {e.X},{e.Y})"; + return true; + } + + foreach (ElementInfo child in e.Children) + { + if (FindElement(child, wanted, out path)) + { + path = $"0x{e.Id:X8} > {path}"; + return true; + } + } + + path = string.Empty; + return false; + } +} + ElementInfo? root = ids.Length > 1 ? LayoutImporter.ImportInfos(adapter, ids[0], ids[1]) : LayoutImporter.ImportInfos(adapter, ids[0]); From ab3934e21db6250ffb112ff145d4c5cfa4bdecc7 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 14:38:00 +0200 Subject: [PATCH 28/43] =?UTF-8?q?feat(quest):=20QT1=20=E2=80=94=20parse=20?= =?UTF-8?q?the=20contract-tracker=20events=20we=20have=20been=20dropping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both opcodes have been named in GameEventType since the wire-catalog work with nothing behind them, so every contract the server has ever sent us arrived and was discarded. Three details that a reimplementation from the enum alone would get wrong, and each has a test: The two trailing flags on 0x0315 are widened bools, not bytes, and they sit OUTSIDE the struct writer — ACE's ContractTracker.Write has them commented out precisely because the event appends them itself. Reading them as bytes decodes the delete flag from the wrong four bytes and silently drops contracts. The stage is not a dense enum. Retail encodes N completed steps as ProgressCounter + N, so a switch over the four named values sees stage 9 as unknown and shows nothing. Progress/HasProgressCounter do that arithmetic once here rather than leaving every caller to remember it. The countdown anchor is not on the wire. FillProgressString @0x00498DE0 counts down from CContractTracker::_time_of_server_update, which the server never sends — so arrival has to be stamped at parse time or the repeat timer has nothing to tick against. An empty table is a valid answer rather than a decode failure: it is how the server says "you have no contracts", and confusing the two would leave stale quests on screen permanently. A truncated one is rejected outright instead of decoding to its prefix, which would drop quests just as silently. Campaign QT slice 1 of 6. Co-Authored-By: Claude Opus 5 --- .../Messages/ContractTrackerMessages.cs | 196 ++++++++++++++++++ .../Messages/ContractTrackerMessagesTests.cs | 185 +++++++++++++++++ 2 files changed, 381 insertions(+) create mode 100644 src/AcDream.Core.Net/Messages/ContractTrackerMessages.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/ContractTrackerMessagesTests.cs diff --git a/src/AcDream.Core.Net/Messages/ContractTrackerMessages.cs b/src/AcDream.Core.Net/Messages/ContractTrackerMessages.cs new file mode 100644 index 00000000..b72eae42 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/ContractTrackerMessages.cs @@ -0,0 +1,196 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; + +namespace AcDream.Core.Net.Messages; + +/// +/// How far along a contract is. +/// +/// +/// Not a dense enum: retail encodes a progress COUNTER by adding the number of +/// completed steps to , so any value at or above 4 +/// is "in progress with (value - 4) done". +/// does that arithmetic rather than leaving callers to remember it. +/// +public enum ContractStage : uint +{ + Available = 1, + InProgress = 2, + DoneOrPendingRepeat = 3, + ProgressCounter = 4, +} + +/// +/// One contract's live state, as the server sees it. +/// +/// The dat Contract.Version this state was built against. +/// Key into the ContractTable dat. +/// See . +/// Seconds until the current cooldown ends. +/// Seconds until the repeat cooldown ends. +/// +/// When this state reached us. NOT on the wire — retail's CContractTracker +/// carries its own _time_of_server_update and +/// gmContractsUI::FillProgressString @0x00498DE0 counts down from it +/// (TimeWhenRepeats - (now - timeOfServerUpdate)). Anchoring at parse +/// time is what makes the countdown tick; recomputing from the server value +/// every frame would freeze it. +/// +public readonly record struct ContractTracker( + uint Version, + uint ContractId, + ContractStage Stage, + double TimeWhenDone, + double TimeWhenRepeats, + DateTime ReceivedAt) +{ + /// The wire size of one tracker struct. + internal const int WireSize = 4 + 4 + 4 + 8 + 8; + + /// + /// Completed steps, when the stage carries a counter; 0 otherwise. + /// + public uint Progress => + (uint)Stage >= (uint)ContractStage.ProgressCounter + ? (uint)Stage - (uint)ContractStage.ProgressCounter + : 0u; + + /// Whether the stage encodes a progress counter at all. + public bool HasProgressCounter => + (uint)Stage >= (uint)ContractStage.ProgressCounter; +} + +/// +/// A single-contract update: 0x0315 SendClientContractTracker. +/// +/// Remove this contract from the tracker entirely. +/// Make this the contract the panel shows. +public readonly record struct ContractTrackerUpdate( + ContractTracker Tracker, + bool Delete, + bool SetAsDisplay); + +/// +/// Parsers for retail's two contract-tracker game events. +/// +/// +/// +/// Both opcodes have been NAMED in since the wire +/// catalog work without anything parsing them, so the bytes have been arriving +/// and being dropped. +/// +/// +/// Layout confirmed from ACE's writers (ContractTrackerExtensions.Write, +/// GameEventSendClientContractTracker, ContractManager.Write) and +/// cross-checked against the retail client's own +/// PackableHashTable<unsigned long, CContractTracker> +/// instantiations at 0x00497C10. +/// +/// +public static class ContractTrackerMessages +{ + /// + /// A table larger than this is a decode error rather than a big quest log: + /// retail pcaps top out around 3,208 bytes, i.e. well under a hundred + /// entries. + /// + private const int MaxTableEntries = 4096; + + /// + /// 0x0315 — one tracker plus the two flags ACE appends AFTER the + /// struct (they are deliberately not part of Write; see its own + /// commented-out lines). + /// + public static ContractTrackerUpdate? ParseUpdate( + ReadOnlySpan payload, DateTime receivedAt) + { + int pos = 0; + try + { + ContractTracker tracker = ReadTracker(payload, ref pos, receivedAt); + uint delete = ReadU32(payload, ref pos); + uint display = ReadU32(payload, ref pos); + return new ContractTrackerUpdate(tracker, delete != 0u, display != 0u); + } + catch (FormatException) + { + return null; + } + } + + /// + /// 0x0314 — the complete replacement table. No trailing flags on + /// this path. + /// + /// + /// The trackers by contract id, or null if the payload does not decode. + /// An EMPTY table is a valid, meaningful answer — it is how the server says + /// "you have no contracts" — so it must not be confused with a decode + /// failure. + /// + public static IReadOnlyDictionary? ParseTable( + ReadOnlySpan payload, DateTime receivedAt) + { + int pos = 0; + try + { + uint header = ReadU32(payload, ref pos); + ushort count = (ushort)(header & 0xFFFFu); + ushort buckets = (ushort)(header >> 16); + + // A zero-bucket table is valid only when it is empty — the early + // return in PackableHashTable::UnPack @0x006B1A86. + if (buckets == 0 && count != 0) + throw new FormatException("invalid contract tracker table"); + if (count > MaxTableEntries) + throw new FormatException("implausible contract tracker count"); + + var result = new Dictionary(count); + for (int i = 0; i < count; i++) + { + uint key = ReadU32(payload, ref pos); + result[key] = ReadTracker(payload, ref pos, receivedAt); + } + + return result; + } + catch (FormatException) + { + return null; + } + } + + private static ContractTracker ReadTracker( + ReadOnlySpan source, ref int pos, DateTime receivedAt) + { + uint version = ReadU32(source, ref pos); + uint contractId = ReadU32(source, ref pos); + uint stage = ReadU32(source, ref pos); + double whenDone = ReadDouble(source, ref pos); + double whenRepeats = ReadDouble(source, ref pos); + return new ContractTracker( + version, + contractId, + (ContractStage)stage, + whenDone, + whenRepeats, + receivedAt); + } + + private static uint ReadU32(ReadOnlySpan source, ref int pos) + { + if (source.Length - pos < 4) throw new FormatException("truncated u32"); + uint value = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(pos, 4)); + pos += 4; + return value; + } + + private static double ReadDouble(ReadOnlySpan source, ref int pos) + { + if (source.Length - pos < 8) throw new FormatException("truncated double"); + double value = BinaryPrimitives.ReadDoubleLittleEndian(source.Slice(pos, 8)); + pos += 8; + return value; + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/ContractTrackerMessagesTests.cs b/tests/AcDream.Core.Net.Tests/Messages/ContractTrackerMessagesTests.cs new file mode 100644 index 00000000..d029999a --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/ContractTrackerMessagesTests.cs @@ -0,0 +1,185 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using AcDream.Core.Net.Messages; + +namespace AcDream.Core.Net.Tests.Messages; + +/// +/// Campaign QT slice QT1: retail's two contract-tracker game events. +/// +public sealed class ContractTrackerMessagesTests +{ + private static readonly DateTime Arrival = new(2026, 8, 21, 13, 5, 9, DateTimeKind.Utc); + + /// Writes one tracker struct exactly as ACE's writer does. + private static byte[] Tracker( + uint version, uint contractId, uint stage, double whenDone, double whenRepeats) + { + var buffer = new byte[28]; + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(0), version); + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4), contractId); + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(8), stage); + BinaryPrimitives.WriteDoubleLittleEndian(buffer.AsSpan(12), whenDone); + BinaryPrimitives.WriteDoubleLittleEndian(buffer.AsSpan(20), whenRepeats); + return buffer; + } + + private static byte[] Concat(params byte[][] parts) + { + var result = new List(); + foreach (byte[] part in parts) result.AddRange(part); + return [.. result]; + } + + private static byte[] U32(uint value) + { + var buffer = new byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(buffer, value); + return buffer; + } + + /// The u16 count / u16 buckets header, packed into one dword. + private static byte[] HashHeader(ushort count, ushort buckets) + => U32((uint)count | ((uint)buckets << 16)); + + // ── 0x0315, the single update ─────────────────────────────────────── + + [Fact] + public void AnUpdateDecodesEveryFieldInOrder() + { + byte[] payload = Concat( + Tracker(3u, 0x1234u, 2u, 120.5, 86400.0), + U32(0u), // DeleteContract + U32(1u)); // SetAsDisplayContract + + ContractTrackerUpdate update = + ContractTrackerMessages.ParseUpdate(payload, Arrival)!.Value; + + Assert.Equal(3u, update.Tracker.Version); + Assert.Equal(0x1234u, update.Tracker.ContractId); + Assert.Equal(ContractStage.InProgress, update.Tracker.Stage); + Assert.Equal(120.5, update.Tracker.TimeWhenDone); + Assert.Equal(86400.0, update.Tracker.TimeWhenRepeats); + Assert.False(update.Delete); + Assert.True(update.SetAsDisplay); + } + + [Fact] + public void TheTwoFlagsAreWidenedBoolsNotBytes() + { + // ACE writes Convert.ToUInt32(bool) AFTER the struct — reading them as + // bytes would decode the delete flag out of the wrong four bytes and + // silently drop contracts. + byte[] payload = Concat(Tracker(1u, 7u, 1u, 0, 0), U32(1u), U32(0u)); + + ContractTrackerUpdate update = + ContractTrackerMessages.ParseUpdate(payload, Arrival)!.Value; + + Assert.True(update.Delete); + Assert.False(update.SetAsDisplay); + } + + [Fact] + public void ArrivalIsStampedBecauseItIsNotOnTheWire() + { + // FillProgressString counts down from _time_of_server_update, which the + // server never sends. Without the stamp there is no anchor and the + // repeat timer cannot tick. + ContractTrackerUpdate update = ContractTrackerMessages.ParseUpdate( + Concat(Tracker(1u, 7u, 3u, 0, 600.0), U32(0u), U32(0u)), Arrival)!.Value; + + Assert.Equal(Arrival, update.Tracker.ReceivedAt); + } + + [Fact] + public void ATruncatedUpdateIsRejectedRatherThanPartiallyDecoded() + { + // The struct alone, with the two flags missing. + Assert.Null(ContractTrackerMessages.ParseUpdate(Tracker(1u, 7u, 1u, 0, 0), Arrival)); + Assert.Null(ContractTrackerMessages.ParseUpdate([], Arrival)); + } + + // ── the progress counter ──────────────────────────────────────────── + + [Theory] + [InlineData(1u, 0u, false)] + [InlineData(2u, 0u, false)] + [InlineData(3u, 0u, false)] + [InlineData(4u, 0u, true)] // counter present, zero done + [InlineData(9u, 5u, true)] + public void TheStageCarriesTheProgressCountAboveFour( + uint stage, uint expectedProgress, bool expectedHasCounter) + { + // Retail encodes N completed steps as ProgressCounter + N rather than + // as a separate field, so a naive enum switch would see stage 9 as an + // unknown value and show nothing. + ContractTracker tracker = ContractTrackerMessages.ParseUpdate( + Concat(Tracker(1u, 7u, stage, 0, 0), U32(0u), U32(0u)), Arrival)!.Value.Tracker; + + Assert.Equal(expectedProgress, tracker.Progress); + Assert.Equal(expectedHasCounter, tracker.HasProgressCounter); + } + + // ── 0x0314, the full table ────────────────────────────────────────── + + [Fact] + public void TheTableDecodesEveryEntryKeyedByContractId() + { + byte[] payload = Concat( + HashHeader(count: 2, buckets: 8), + U32(0x1111u), Tracker(1u, 0x1111u, 1u, 0, 0), + U32(0x2222u), Tracker(1u, 0x2222u, 6u, 10.0, 20.0)); + + IReadOnlyDictionary table = + ContractTrackerMessages.ParseTable(payload, Arrival)!; + + Assert.Equal(2, table.Count); + Assert.Equal(ContractStage.Available, table[0x1111u].Stage); + Assert.Equal(2u, table[0x2222u].Progress); + Assert.Equal(Arrival, table[0x2222u].ReceivedAt); + } + + [Fact] + public void AnEmptyTableIsAValidAnswerNotAFailure() + { + // "You have no contracts" is a real thing the server says, and it is + // how the panel gets cleared. Returning null here would leave stale + // contracts on screen forever. + IReadOnlyDictionary? table = + ContractTrackerMessages.ParseTable(HashHeader(0, 0), Arrival); + + Assert.NotNull(table); + Assert.Empty(table!); + } + + [Fact] + public void ANonEmptyTableWithNoBucketsIsRejected() + { + // PackableHashTable::UnPack early-returns on a zero-bucket table, so a + // count without buckets is a corrupt frame, not an empty one. + Assert.Null(ContractTrackerMessages.ParseTable( + Concat(HashHeader(count: 1, buckets: 0), U32(1u), Tracker(1u, 1u, 1u, 0, 0)), + Arrival)); + } + + [Fact] + public void ATruncatedTableIsRejectedRatherThanReturningThePrefix() + { + // Claiming two entries and supplying one must not decode as one — a + // half-read table would silently drop the player's quests. + Assert.Null(ContractTrackerMessages.ParseTable( + Concat(HashHeader(count: 2, buckets: 8), U32(1u), Tracker(1u, 1u, 1u, 0, 0)), + Arrival)); + } + + [Fact] + public void AnImplausibleCountIsRejectedWithoutAllocatingForIt() + { + // Retail's largest observed table is ~3 KB. A count field of 60,000 + // against a short payload is a decode error; it must fail fast rather + // than try to read 60,000 entries. + Assert.Null(ContractTrackerMessages.ParseTable( + HashHeader(count: 60000, buckets: 256), Arrival)); + } +} From f629ce7f3d635166077ad433d92860b09103150b Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 14:46:53 +0200 Subject: [PATCH 29/43] =?UTF-8?q?feat(quest):=20QT3=20=E2=80=94=20the=20co?= =?UTF-8?q?ntract=20tracker=20becomes=20state,=20and=20the=20events=20get?= =?UTF-8?q?=20routed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth sibling J-owner, built to the shape the other three established. It borrows nothing, because the retail client stores no quest state of its own — everything here is a projection of what the server pushed. Clearing at generation reset is safe for the same reason: a fresh session opens with a full 0x0314 replacement, so the reset cannot lose anything the next login will not immediately restate, while NOT clearing would show a previous character's quests. Three readings of the wire that would each lose contracts silently, one test apiece: a 0x0314 REPLACES rather than merges (merging resurrects contracts the server dropped); an empty 0x0314 clears rather than being ignored (it is how the server says "you have none", and ignoring it strands the last quest on screen); and a delete carries a full tracker struct, so it looks exactly like an add apart from one flag. Adding a teardown stage exposed a genuine trap: TeardownStageCount bounds the drain loop while GameRuntimeTeardownStage.Complete defines what the ledger demands, and nothing tied them together. Leave the constant behind and the new owner is never disposed at all, while the ledger goes on waiting for its flag — the runtime hangs in teardown rather than failing anywhere near the edit. The stage-ledger test now reads the constant by reflection and asserts it against the flag list, so the next owner fails at the edit instead. Campaign QT slice 3 of 6. Co-Authored-By: Claude Opus 5 --- .../Net/LiveSessionRuntimeFactory.cs | 3 +- src/AcDream.Core.Net/GameEventWiring.cs | 30 ++- .../Hosting/HeadlessSessionHost.cs | 3 +- src/AcDream.Runtime/GameRuntime.cs | 45 +++- .../Gameplay/RuntimeContractState.cs | 201 +++++++++++++++ src/AcDream.Runtime/RuntimeGenerationReset.cs | 36 ++- .../Session/LiveSessionEventRouter.cs | 13 +- .../AcDream.Runtime.Tests/GameRuntimeTests.cs | 15 ++ .../Gameplay/RuntimeContractStateTests.cs | 237 ++++++++++++++++++ 9 files changed, 564 insertions(+), 19 deletions(-) create mode 100644 src/AcDream.Runtime/Gameplay/RuntimeContractState.cs create mode 100644 tests/AcDream.Runtime.Tests/Gameplay/RuntimeContractStateTests.cs diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index 6460bc14..b3acfe7d 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -393,7 +393,8 @@ internal sealed class LiveSessionRuntimeFactory Fellowship: _domain.Runtime.FellowshipOwner, Allegiance: _domain.Runtime.AllegianceOwner, Trade: _domain.Runtime.TradeOwner, - House: _domain.Runtime.HouseOwner)); + House: _domain.Runtime.HouseOwner, + Contracts: _domain.Runtime.ContractsOwner)); return new GraphicalSessionEventRoute( route, _domain.Runtime, diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index 13afc2be..81d3160f 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -131,7 +131,14 @@ public static class GameEventWiring Action? onHouseData = null, Action? onHouseStatus = null, Action? onHouseUpdateRentTime = null, - Action>? onHouseUpdateRentPayment = null) + Action>? onHouseUpdateRentPayment = null, + // Campaign QT (2026-08-21): the contract tracker's two events. Same + // Runtime-owned delegate-hole shape as house/trade above -- + // RuntimeContractState is the consumer. Both opcodes have been named + // 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) { ArgumentNullException.ThrowIfNull(dispatcher); ArgumentNullException.ThrowIfNull(items); @@ -446,6 +453,27 @@ public static class GameEventWiring }); } + // Campaign QT (2026-08-21). Arrival is stamped HERE rather than + // inside the parser's caller, because FillProgressString counts a + // repeat timer down from the moment the state arrived and the server + // never sends that moment. + if (onContractTable is not null) + { + registrar.Register(GameEventType.SendClientContractTrackerTable, e => + { + var p = ContractTrackerMessages.ParseTable(e.Payload.Span, DateTime.UtcNow); + if (p is not null) onContractTable(p); + }); + } + if (onContractUpdate is not null) + { + registrar.Register(GameEventType.SendClientContractTracker, e => + { + var p = ContractTrackerMessages.ParseUpdate(e.Payload.Span, DateTime.UtcNow); + if (p is not null) onContractUpdate(p.Value); + }); + } + if (onConfirmationRequest is not null) { registrar.Register(GameEventType.CharacterConfirmationRequest, e => diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 16a4ed51..a0140d36 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -1250,7 +1250,8 @@ internal sealed class HeadlessSessionHost : IDisposable Runtime.CommunicationOwner.Squelch, (text, type) => Runtime.CommunicationOwner.AddText(text, type), Fellowship: Runtime.FellowshipOwner, - Allegiance: Runtime.AllegianceOwner)); + Allegiance: Runtime.AllegianceOwner, + Contracts: Runtime.ContractsOwner)); var eventRoute = new HeadlessSessionEventRoute( route, Runtime, diff --git a/src/AcDream.Runtime/GameRuntime.cs b/src/AcDream.Runtime/GameRuntime.cs index f89374fd..475fa3ff 100644 --- a/src/AcDream.Runtime/GameRuntime.cs +++ b/src/AcDream.Runtime/GameRuntime.cs @@ -52,6 +52,8 @@ public enum GameRuntimeTeardownStage TradeDisposed = 1 << 11, IdentityDisposed = 1 << 12, EntityObjectsDisposed = 1 << 13, + // Campaign QT (2026-08-21): fourth sibling J-owner, same shape. + ContractsDisposed = 1 << 14, Complete = HostLeasesReleased | EventsDetached @@ -65,6 +67,7 @@ public enum GameRuntimeTeardownStage | FellowshipDisposed | AllegianceDisposed | TradeDisposed + | ContractsDisposed | IdentityDisposed | EntityObjectsDisposed, } @@ -109,6 +112,7 @@ internal enum GameRuntimeConstructionPoint FellowshipCreated, AllegianceCreated, TradeCreated, + ContractsCreated, HouseCreated, MovementCreated, ActionsCreated, @@ -128,6 +132,7 @@ internal sealed class GameRuntimeConstructionContext public RuntimeFellowshipState? Fellowship { get; set; } public RuntimeAllegianceState? Allegiance { get; set; } public RuntimeTradeState? Trade { get; set; } + public RuntimeContractState? Contracts { get; set; } public RuntimeHouseState? House { get; set; } public RuntimeLocalPlayerMovementState? Movement { get; set; } public RuntimeActionState? Actions { get; set; } @@ -144,7 +149,11 @@ public sealed class GameRuntime IRuntimeEventSource, IDisposable { - private const int TeardownStageCount = 14; + // Campaign QT (2026-08-21): 15 with the contract owner. This bound and + // GameRuntimeTeardownStage.Complete have to move together — the drain + // loop stops here, so leaving it behind would silently never dispose + // the last owner while the ledger kept demanding its flag. + private const int TeardownStageCount = 15; private readonly object _lifetimeGate = new(); private readonly Dictionary _hostLeases = []; @@ -284,6 +293,17 @@ public sealed class GameRuntime context, faultInjection); + // Campaign QT (2026-08-21): fourth sibling J-owner. A pure + // projection of server-pushed contract state — it borrows + // nothing, because the retail client stores no quest state of + // its own. + context.Contracts = new RuntimeContractState(); + construction.Own(context.Contracts); + Fault( + GameRuntimeConstructionPoint.ContractsCreated, + context, + faultInjection); + // House tab (Batch C, Map/House toolbar panel, 2026-08-17): // deliberately minimal owner (ISSUES #413's own sizing note) — // no live-object side effects, nothing to dispose, so no @@ -351,7 +371,8 @@ public sealed class GameRuntime context.Fellowship, context.Allegiance, context.Trade, - context.House); + context.House, + context.Contracts); context.Movement.AttachPhysicsPublication( new RuntimeLocalPlayerPhysicsPublicationState( @@ -407,6 +428,7 @@ public sealed class GameRuntime FellowshipOwner = context.Fellowship; AllegianceOwner = context.Allegiance; TradeOwner = context.Trade; + ContractsOwner = context.Contracts; HouseOwner = context.House; MovementOwner = context.Movement; ActionOwner = context.Actions; @@ -516,6 +538,7 @@ public sealed class GameRuntime /// Secure trade (2026-08-14): third sibling J-owner. public RuntimeTradeState TradeOwner { get; } + public RuntimeContractState ContractsOwner { get; } /// Batch C (2026-08-17): House tab minimal owner — see /// 's own class doc for the sizing @@ -572,6 +595,7 @@ public sealed class GameRuntime public IRuntimeAllegianceView Allegiance => AllegianceOwner.View; public IRuntimeTradeView Trade => TradeOwner.View; + public IRuntimeContractView Contracts => ContractsOwner.View; public IRuntimeActionView Actions => ActionOwner.View; public IRuntimeMovementView Movement => MovementOwner.View; public IRuntimeWorldEnvironmentView Environment => EnvironmentOwner; @@ -800,21 +824,28 @@ public sealed class GameRuntime & ~GameRuntimeTeardownStage.FellowshipDisposed & ~GameRuntimeTeardownStage.AllegianceDisposed & ~GameRuntimeTeardownStage.TradeDisposed + & ~GameRuntimeTeardownStage.ContractsDisposed & ~GameRuntimeTeardownStage.IdentityDisposed & ~GameRuntimeTeardownStage.EntityObjectsDisposed, 10 => GameRuntimeTeardownStage.Complete & ~GameRuntimeTeardownStage.AllegianceDisposed & ~GameRuntimeTeardownStage.TradeDisposed + & ~GameRuntimeTeardownStage.ContractsDisposed & ~GameRuntimeTeardownStage.IdentityDisposed & ~GameRuntimeTeardownStage.EntityObjectsDisposed, 11 => GameRuntimeTeardownStage.Complete & ~GameRuntimeTeardownStage.TradeDisposed + & ~GameRuntimeTeardownStage.ContractsDisposed & ~GameRuntimeTeardownStage.IdentityDisposed & ~GameRuntimeTeardownStage.EntityObjectsDisposed, 12 => GameRuntimeTeardownStage.Complete + & ~GameRuntimeTeardownStage.ContractsDisposed & ~GameRuntimeTeardownStage.IdentityDisposed & ~GameRuntimeTeardownStage.EntityObjectsDisposed, 13 => GameRuntimeTeardownStage.Complete + & ~GameRuntimeTeardownStage.IdentityDisposed + & ~GameRuntimeTeardownStage.EntityObjectsDisposed, + 14 => GameRuntimeTeardownStage.Complete & ~GameRuntimeTeardownStage.EntityObjectsDisposed, _ => GameRuntimeTeardownStage.Complete, }; @@ -869,9 +900,12 @@ public sealed class GameRuntime TradeOwner.Dispose(); return TradeOwner.CaptureOwnership().IsConverged; case 12: + ContractsOwner.Dispose(); + return ContractsOwner.CaptureOwnership().IsConverged; + case 13: PlayerIdentity.Dispose(); return PlayerIdentity.CaptureOwnership().IsConverged; - case 13: + case 14: EntityObjects.Dispose(); return EntityObjects.CaptureOwnership().IsConverged && EntityObjects.Physics.CaptureOwnership().IsConverged; @@ -895,8 +929,9 @@ public sealed class GameRuntime 9 => FellowshipOwner.CaptureOwnership().IsConverged, 10 => AllegianceOwner.CaptureOwnership().IsConverged, 11 => TradeOwner.CaptureOwnership().IsConverged, - 12 => PlayerIdentity.CaptureOwnership().IsConverged, - 13 => EntityObjects.CaptureOwnership().IsConverged + 12 => ContractsOwner.CaptureOwnership().IsConverged, + 13 => PlayerIdentity.CaptureOwnership().IsConverged, + 14 => EntityObjects.CaptureOwnership().IsConverged && EntityObjects.Physics.CaptureOwnership().IsConverged, _ => true, }; diff --git a/src/AcDream.Runtime/Gameplay/RuntimeContractState.cs b/src/AcDream.Runtime/Gameplay/RuntimeContractState.cs new file mode 100644 index 00000000..eb5217ba --- /dev/null +++ b/src/AcDream.Runtime/Gameplay/RuntimeContractState.cs @@ -0,0 +1,201 @@ +using AcDream.Core.Net.Messages; + +namespace AcDream.Runtime.Gameplay; + +public readonly record struct RuntimeContractOwnershipSnapshot( + bool IsDisposed, + int ContractCount, + uint DisplayContractId) +{ + public bool IsConverged => + IsDisposed + && ContractCount == 0 + && DisplayContractId == 0u; +} + +/// Whole-tracker state at one revision. +public readonly record struct RuntimeContractsSnapshot( + long Revision, + int ContractCount, + uint DisplayContractId); + +/// Borrowed read surface over . +public interface IRuntimeContractView +{ + RuntimeContractsSnapshot Snapshot { get; } + + bool TryGetContract(uint contractId, out ContractTracker tracker); + + /// Every tracked contract, ordered by contract id for stable display. + IReadOnlyList GetContracts(); +} + +/// +/// Canonical presentation-independent owner for the player's contract tracker +/// — Campaign QT slice QT3. +/// +/// +/// +/// Session-scoped, and unusually safe to make so: the retail client stores NO +/// quest state of its own (r10-quest-dialogs.md §1.3). Everything here +/// is a projection of what the server pushed, and a fresh session opens with a +/// full 0x0314 replacement, so clearing at generation reset cannot lose +/// anything the next login will not immediately restate. +/// +/// +/// Three mutation shapes, all from QT1's parsers: +/// 0x0314 REPLACES the table wholesale; 0x0315 upserts one +/// contract, or removes it when DeleteContract is set; and either path +/// may nominate the display contract. Every mutation bumps the monotonic +/// revision so consumers poll rather than subscribe. +/// +/// +public sealed class RuntimeContractState : IDisposable +{ + private readonly object _gate = new(); + private readonly Dictionary _contracts = []; + private uint _displayContractId; + private long _revision; + private bool _disposed; + + public RuntimeContractState() => View = new ContractView(this); + + public IRuntimeContractView View { get; } + + /// + /// 0x0314 — the server's complete list replaces ours. + /// + /// + /// An EMPTY table is meaningful and must clear: it is how the server says + /// "you have no contracts". Treating empty as "nothing to do" would strand + /// contracts on screen after the last one is abandoned. + /// + /// A display contract that is not in the new table is dropped, because a + /// panel pointed at a contract the server no longer tracks has nothing to + /// render. + /// + /// + public void ApplyTable(IReadOnlyDictionary table) + { + ArgumentNullException.ThrowIfNull(table); + lock (_gate) + { + if (_disposed) return; + + _contracts.Clear(); + foreach ((uint id, ContractTracker tracker) in table) + _contracts[id] = tracker; + + if (_displayContractId != 0u && !_contracts.ContainsKey(_displayContractId)) + _displayContractId = 0u; + + Bump(); + } + } + + /// + /// 0x0315 — one contract added, changed, or removed. + /// + /// + /// The delete flag is checked BEFORE the upsert. A delete carries a whole + /// tracker struct alongside it (ACE builds one either way), so storing + /// first and deleting second would work, but reading the message as + /// "here is a contract" when it says "remove this contract" is the + /// misreading worth ruling out. + /// + public void ApplyUpdate(ContractTrackerUpdate update) + { + lock (_gate) + { + if (_disposed) return; + + uint id = update.Tracker.ContractId; + if (update.Delete) + { + bool removed = _contracts.Remove(id); + if (_displayContractId == id) + _displayContractId = 0u; + if (removed) Bump(); + return; + } + + _contracts[id] = update.Tracker; + if (update.SetAsDisplay) + _displayContractId = id; + Bump(); + } + } + + public RuntimeContractOwnershipSnapshot CaptureOwnership() + { + lock (_gate) + return new RuntimeContractOwnershipSnapshot( + _disposed, + _contracts.Count, + _displayContractId); + } + + /// + /// Session-scoped: cleared at every generation reset. No disposed guard, + /// matching the sibling owners — the reset transaction is retryable and + /// disposal is terminal, so a throwing guard here could never converge. + /// + public void ResetSession() + { + lock (_gate) ClearLocked(); + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) return; + ClearLocked(); + _disposed = true; + } + } + + private void ClearLocked() + { + bool changed = _contracts.Count != 0 || _displayContractId != 0u; + _contracts.Clear(); + _displayContractId = 0u; + if (changed) Bump(); + } + + private void Bump() => _revision++; + + private sealed class ContractView(RuntimeContractState owner) : IRuntimeContractView + { + public RuntimeContractsSnapshot Snapshot + { + get + { + lock (owner._gate) + return new RuntimeContractsSnapshot( + owner._revision, + owner._contracts.Count, + owner._displayContractId); + } + } + + public bool TryGetContract(uint contractId, out ContractTracker tracker) + { + lock (owner._gate) + return owner._contracts.TryGetValue(contractId, out tracker); + } + + public IReadOnlyList GetContracts() + { + lock (owner._gate) + { + var result = new ContractTracker[owner._contracts.Count]; + int i = 0; + foreach (ContractTracker tracker in owner._contracts.Values) + result[i++] = tracker; + Array.Sort(result, static (a, b) => a.ContractId.CompareTo(b.ContractId)); + return result; + } + } + } +} diff --git a/src/AcDream.Runtime/RuntimeGenerationReset.cs b/src/AcDream.Runtime/RuntimeGenerationReset.cs index 28441e7c..e497507a 100644 --- a/src/AcDream.Runtime/RuntimeGenerationReset.cs +++ b/src/AcDream.Runtime/RuntimeGenerationReset.cs @@ -69,15 +69,24 @@ public enum RuntimeGenerationResetStage /// construction-transaction Fault() point). /// House = 15, - BeginEntityRetirement = 16, - RetireEntities = 17, - DrainHostProjection = 18, - CompleteCanonicalEntities = 19, - CompleteHostProjection = 20, - ChatIdentity = 21, - PlayerSnapshots = 22, - PlayerIdentity = 23, - Complete = 24, + /// + /// Campaign QT (2026-08-21): the contract tracker is a projection of + /// server state and nothing else — the retail client stores no quest + /// state of its own. A fresh session opens with a full 0x0314 + /// replacement, so clearing here cannot lose anything the next login + /// will not immediately restate, while NOT clearing would show a + /// previous character's quests. + /// + Contracts = 16, + BeginEntityRetirement = 17, + RetireEntities = 18, + DrainHostProjection = 19, + CompleteCanonicalEntities = 20, + CompleteHostProjection = 21, + ChatIdentity = 22, + PlayerSnapshots = 23, + PlayerIdentity = 24, + Complete = 25, } public readonly record struct RuntimeGenerationResetSnapshot( @@ -128,6 +137,7 @@ public sealed class RuntimeGenerationReset private readonly RuntimeFellowshipState _fellowship; private readonly RuntimeAllegianceState _allegiance; private readonly RuntimeTradeState _trade; + private readonly RuntimeContractState _contracts; private readonly RuntimeHouseState _house; private ResetState? _state; private RuntimeGenerationToken _lastCompletedGeneration; @@ -147,7 +157,8 @@ public sealed class RuntimeGenerationReset RuntimeFellowshipState fellowship, RuntimeAllegianceState allegiance, RuntimeTradeState trade, - RuntimeHouseState house) + RuntimeHouseState house, + RuntimeContractState contracts) { _transit = transit ?? throw new ArgumentNullException(nameof(transit)); _communication = communication @@ -169,6 +180,8 @@ public sealed class RuntimeGenerationReset ?? throw new ArgumentNullException(nameof(allegiance)); _trade = trade ?? throw new ArgumentNullException(nameof(trade)); _house = house ?? throw new ArgumentNullException(nameof(house)); + _contracts = contracts + ?? throw new ArgumentNullException(nameof(contracts)); } public RuntimeGenerationToken? ActiveRetiringGeneration => @@ -348,6 +361,9 @@ public sealed class RuntimeGenerationReset case RuntimeGenerationResetStage.House: Advance(state, _house.ResetSession); break; + case RuntimeGenerationResetStage.Contracts: + Advance(state, _contracts.ResetSession); + break; case RuntimeGenerationResetStage.BeginEntityRetirement: _ = _entityObjects.BeginSessionClear(); state.Retirements = _entityObjects diff --git a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs index 53179aae..f1caafdf 100644 --- a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs +++ b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs @@ -92,7 +92,10 @@ public sealed record LiveSocialSessionBindings( // Batch C (Map/House toolbar panel, 2026-08-17): same trailing/optional // compatibility convention — a minimal owner (RuntimeHouseState's own // class doc), not a full sibling J-owner. - RuntimeHouseState? House = null); + RuntimeHouseState? House = null, + // Campaign QT (2026-08-21): the fourth sibling J-owner, same + // trailing/optional compatibility convention. + RuntimeContractState? Contracts = null); /// /// Owns every inbound subscription for one exact live session. Domain state @@ -334,6 +337,14 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting : null, onHouseStatus: social.House is { } houseStatus ? weenieError => houseStatus.ApplyHouseStatus(weenieError, inventory.PlayerGuid()) + : null, + // Campaign QT (2026-08-21): same conditional delegate-hole + // discipline as house above. + onContractTable: social.Contracts is { } contractTable + ? contractTable.ApplyTable + : null, + onContractUpdate: social.Contracts is { } contractUpdate + ? contractUpdate.ApplyUpdate : null)); ConstructionCheckpoint(); diff --git a/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs b/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs index d42f126d..cc59f3e9 100644 --- a/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs +++ b/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs @@ -240,6 +240,10 @@ public sealed class GameRuntimeTests GameRuntimeTeardownStage.FellowshipDisposed, GameRuntimeTeardownStage.AllegianceDisposed, GameRuntimeTeardownStage.TradeDisposed, + // Campaign QT (2026-08-21): the contract owner disposes beside + // its three sibling J-owners, before the identity and entity + // foundations. + GameRuntimeTeardownStage.ContractsDisposed, GameRuntimeTeardownStage.IdentityDisposed, GameRuntimeTeardownStage.EntityObjectsDisposed, ]; @@ -255,6 +259,17 @@ public sealed class GameRuntimeTests } Assert.Equal(GameRuntimeTeardownStage.Complete, expected); + + // The drain loop stops at TeardownStageCount, so a new owner added to + // the enum without bumping the constant is never disposed at all while + // the ledger goes on demanding its flag — the runtime then hangs in + // teardown forever rather than failing anywhere near the mistake. + // Campaign QT hit exactly this; tying the two together here is what + // makes it fail at the edit instead. + int stageCount = (int)typeof(GameRuntime) + .GetField("TeardownStageCount", BindingFlags.NonPublic | BindingFlags.Static)! + .GetRawConstantValue()!; + Assert.Equal(orderedFlags.Length, stageCount); } private static GameRuntime Create() => new(Dependencies()); diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeContractStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeContractStateTests.cs new file mode 100644 index 00000000..1f63a2fa --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeContractStateTests.cs @@ -0,0 +1,237 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AcDream.Core.Net.Messages; +using AcDream.Runtime.Gameplay; + +namespace AcDream.Runtime.Tests.Gameplay; + +/// +/// Campaign QT slice QT3: the canonical contract-tracker owner. +/// +public sealed class RuntimeContractStateTests +{ + private static readonly DateTime Arrival = new(2026, 8, 21, 13, 0, 0, DateTimeKind.Utc); + + private static ContractTracker Tracker( + uint contractId, + ContractStage stage = ContractStage.InProgress, + double whenRepeats = 0) + => new(1u, contractId, stage, 0, whenRepeats, Arrival); + + private static ContractTrackerUpdate Update( + uint contractId, + ContractStage stage = ContractStage.InProgress, + bool delete = false, + bool setAsDisplay = false) + => new(Tracker(contractId, stage), delete, setAsDisplay); + + [Fact] + public void AnUpdateAddsAContractAndASecondUpdateReplacesIt() + { + using var state = new RuntimeContractState(); + + state.ApplyUpdate(Update(0x10u, ContractStage.Available)); + state.ApplyUpdate(Update(0x10u, ContractStage.InProgress)); + + Assert.True(state.View.TryGetContract(0x10u, out ContractTracker tracker)); + Assert.Equal(ContractStage.InProgress, tracker.Stage); + Assert.Equal(1, state.View.Snapshot.ContractCount); + } + + [Fact] + public void TheDeleteFlagRemovesTheContractItNames() + { + // ACE builds a full tracker struct even for a delete, so the message + // looks exactly like an add apart from one flag. Reading it as an add + // would make abandoned quests immortal. + using var state = new RuntimeContractState(); + state.ApplyUpdate(Update(0x10u)); + + state.ApplyUpdate(Update(0x10u, delete: true)); + + Assert.False(state.View.TryGetContract(0x10u, out _)); + Assert.Equal(0, state.View.Snapshot.ContractCount); + } + + [Fact] + public void DeletingTheDisplayContractClearsTheDisplaySelection() + { + // A panel pointed at a contract the server no longer tracks has + // nothing to render. + using var state = new RuntimeContractState(); + state.ApplyUpdate(Update(0x10u, setAsDisplay: true)); + Assert.Equal(0x10u, state.View.Snapshot.DisplayContractId); + + state.ApplyUpdate(Update(0x10u, delete: true)); + + Assert.Equal(0u, state.View.Snapshot.DisplayContractId); + } + + [Fact] + public void ATableReplacesEverythingRatherThanMergingIntoIt() + { + // 0x0314 is a full replacement. Merging would resurrect contracts the + // server has dropped. + using var state = new RuntimeContractState(); + state.ApplyUpdate(Update(0x10u)); + state.ApplyUpdate(Update(0x20u)); + + state.ApplyTable(new Dictionary + { + [0x30u] = Tracker(0x30u), + }); + + Assert.False(state.View.TryGetContract(0x10u, out _)); + Assert.True(state.View.TryGetContract(0x30u, out _)); + Assert.Equal(1, state.View.Snapshot.ContractCount); + } + + [Fact] + public void AnEmptyTableClearsTheTracker() + { + // "You have no contracts" is a real thing the server says. Ignoring an + // empty table would strand the last quest on screen forever. + using var state = new RuntimeContractState(); + state.ApplyUpdate(Update(0x10u, setAsDisplay: true)); + + state.ApplyTable(new Dictionary()); + + Assert.Equal(0, state.View.Snapshot.ContractCount); + Assert.Equal(0u, state.View.Snapshot.DisplayContractId); + } + + [Fact] + public void ATableThatDropsTheDisplayContractClearsTheSelection() + { + using var state = new RuntimeContractState(); + state.ApplyUpdate(Update(0x10u, setAsDisplay: true)); + + state.ApplyTable(new Dictionary { [0x20u] = Tracker(0x20u) }); + + Assert.Equal(0u, state.View.Snapshot.DisplayContractId); + } + + [Fact] + public void ATableThatKeepsTheDisplayContractKeepsTheSelection() + { + // A routine full refresh must not deselect what the player is looking + // at. + using var state = new RuntimeContractState(); + state.ApplyUpdate(Update(0x10u, setAsDisplay: true)); + + state.ApplyTable(new Dictionary { [0x10u] = Tracker(0x10u) }); + + Assert.Equal(0x10u, state.View.Snapshot.DisplayContractId); + } + + [Fact] + public void ContractsComeBackInAStableOrder() + { + // The panel draws a list; an unordered dictionary would reshuffle the + // rows under the cursor on every refresh. + using var state = new RuntimeContractState(); + state.ApplyUpdate(Update(0x30u)); + state.ApplyUpdate(Update(0x10u)); + state.ApplyUpdate(Update(0x20u)); + + IReadOnlyList contracts = state.View.GetContracts(); + + Assert.Equal( + new uint[] { 0x10u, 0x20u, 0x30u }, + contracts.Select(c => c.ContractId).ToArray()); + } + + [Fact] + public void EveryMutationAdvancesTheRevision() + { + // Consumers poll rather than subscribe, so a mutation that does not + // bump is a mutation the panel never draws. + using var state = new RuntimeContractState(); + long start = state.View.Snapshot.Revision; + + state.ApplyUpdate(Update(0x10u)); + long afterAdd = state.View.Snapshot.Revision; + state.ApplyTable(new Dictionary()); + long afterTable = state.View.Snapshot.Revision; + + Assert.True(afterAdd > start); + Assert.True(afterTable > afterAdd); + } + + [Fact] + public void DeletingSomethingAbsentDoesNotAdvanceTheRevision() + { + // A no-op that bumps would redraw the panel on every stray message. + using var state = new RuntimeContractState(); + long start = state.View.Snapshot.Revision; + + state.ApplyUpdate(Update(0x99u, delete: true)); + + Assert.Equal(start, state.View.Snapshot.Revision); + } + + [Fact] + public void ResetSessionClearsBecauseQuestStateIsPurelyServerSide() + { + // A reconnect must not show the previous character's quests. Safe + // because the next login opens with a full 0x0314. + using var state = new RuntimeContractState(); + state.ApplyUpdate(Update(0x10u, setAsDisplay: true)); + + state.ResetSession(); + + Assert.Equal(0, state.View.Snapshot.ContractCount); + Assert.Equal(0u, state.View.Snapshot.DisplayContractId); + } + + [Fact] + public void ResetSessionIsSafeToRepeatBecauseTheResetTransactionRetries() + { + using var state = new RuntimeContractState(); + state.ApplyUpdate(Update(0x10u)); + + state.ResetSession(); + state.ResetSession(); + + Assert.Equal(0, state.View.Snapshot.ContractCount); + } + + [Fact] + public void OwnershipConvergesOnlyAfterDisposal() + { + var state = new RuntimeContractState(); + state.ApplyUpdate(Update(0x10u, setAsDisplay: true)); + Assert.False(state.CaptureOwnership().IsConverged); + + state.Dispose(); + + RuntimeContractOwnershipSnapshot ownership = state.CaptureOwnership(); + Assert.True(ownership.IsConverged); + Assert.Equal(0, ownership.ContractCount); + Assert.Equal(0u, ownership.DisplayContractId); + } + + [Fact] + public void MutationsAfterDisposalAreIgnoredRatherThanThrowing() + { + // Teardown is terminal and a late inbound packet must not resurrect + // state or take the process down. + var state = new RuntimeContractState(); + state.Dispose(); + + state.ApplyUpdate(Update(0x10u)); + state.ApplyTable(new Dictionary { [0x20u] = Tracker(0x20u) }); + + Assert.True(state.CaptureOwnership().IsConverged); + } + + [Fact] + public void DisposalIsIdempotent() + { + var state = new RuntimeContractState(); + state.Dispose(); + state.Dispose(); + Assert.True(state.CaptureOwnership().IsConverged); + } +} From ef6b7310c57da1cc47480943c03ae662d7d748df Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 14:53:21 +0200 Subject: [PATCH 30/43] =?UTF-8?q?feat(quest):=20QT2/QT4=20=E2=80=94=20the?= =?UTF-8?q?=20contract=20catalog,=20and=20retail's=20progress=20column?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wire carries an id, a stage and two timers. Every word the player reads lives in portal.dat's ContractTable, which nothing in the tree had ever opened — the only reference counted its entries in a CLI diagnostic. Chorizite does decode it (322 contracts installed), which was a real question given it declares TabooTable without decoding it. FillProgressString @0x00498DE0 is the one real algorithm in this panel, and it is now ported whole. Its x87 compares are the usual fcom/sahf pattern, so the (status & 0x41) tests decode as "<= 0" rather than "< 0" — the difference between a cooldown that expires and one that never does. Three readings recorded as tests because each looks like a mistake: TimeWhenDone is on the wire and is never read; an EMPTY QuestflagRepeatTime is the entire difference between "Done" and "Available"; and DescriptionProgress is a printf format taking stage-4, not a literal — rendering it verbatim shows the player "%d/20 Tuskers". DeltaTimeToString @0x00565E10 emits every part with a trailing space and then overwrites the last one. That truncation is invisible in the decompiler output (the instruction reads as pointer noise), so it was settled by decoding the bytes: mov byte ptr [esp+eax+0x1b], cl with cl == 0 and eax == strlen writes the terminator over buffer[len-1]. Guessing either way was a coin flip that decides whether every repeat timer reads "Done (1h 30s to Repeat)". The single-%d substitution is a MEASUREMENT, not a convenience: 89 of the 322 installed contracts author a progress format and every one uses exactly one specifier. An installed-DAT test asserts that, so a future dat that ships two fails there rather than silently rendering a raw specifier. LayoutDump gained --contracts, which is how all of the above was measured. Campaign QT slices 2 and 4 of 6. Co-Authored-By: Claude Opus 5 --- src/AcDream.Content/ContractTableReader.cs | 69 +++++++ src/AcDream.Core/Quests/ContractEntry.cs | 73 +++++++ .../Quests/ContractProgressText.cs | 160 +++++++++++++++ .../ContractTableReaderInstalledDatTests.cs | 103 ++++++++++ .../Quests/ContractProgressTextTests.cs | 183 ++++++++++++++++++ tools/LayoutDump/Program.cs | 49 +++++ 6 files changed, 637 insertions(+) create mode 100644 src/AcDream.Content/ContractTableReader.cs create mode 100644 src/AcDream.Core/Quests/ContractEntry.cs create mode 100644 src/AcDream.Core/Quests/ContractProgressText.cs create mode 100644 tests/AcDream.Content.Tests/ContractTableReaderInstalledDatTests.cs create mode 100644 tests/AcDream.Core.Tests/Quests/ContractProgressTextTests.cs diff --git a/src/AcDream.Content/ContractTableReader.cs b/src/AcDream.Content/ContractTableReader.cs new file mode 100644 index 00000000..9c7f87d7 --- /dev/null +++ b/src/AcDream.Content/ContractTableReader.cs @@ -0,0 +1,69 @@ +using System.Collections.Frozen; +using System.Collections.Generic; +using AcDream.Core.Quests; +using DatContractTable = DatReaderWriter.DBObjs.ContractTable; + +namespace AcDream.Content; + +/// +/// Projects portal.dat's ContractTable into acdream's presentation-free +/// . +/// +/// +/// Same shape as and +/// MagicCatalog.Load: one static entry point over +/// , frozen at projection, and no Chorizite +/// types crossing into the returned model. +/// +/// Nothing read this table before Campaign QT — the only reference in the tree +/// counted its entries in a CLI diagnostic. The installed build holds 322 +/// contracts. +/// +/// +public static class ContractTableReader +{ + /// + /// Retail's ContractTable dat id (ACE: + /// ACE.DatLoader.FileTypes.ContractTable.FILE_ID). + /// + public const uint ContractTableDid = 0x0E00001Du; + + /// + /// Loads the installed contract catalog, or + /// when the table is absent. + /// + /// + /// An absent table is not fatal. It costs the player the contract NAMES, + /// not the tracker: the wire state stands on its own, and the panel still + /// has stages and timers to draw. + /// + public static ContractCatalog Load(IDatReaderWriter dats) + { + ArgumentNullException.ThrowIfNull(dats); + + DatContractTable? table = dats.Get(ContractTableDid); + if (table is null || table.Contracts.Count == 0) + return ContractCatalog.Empty; + + var projected = new Dictionary(table.Contracts.Count); + foreach ((uint key, DatReaderWriter.Types.Contract contract) in table.Contracts) + { + projected[key] = new ContractEntry( + contract.Version, + contract.ContractId, + contract.ContractName ?? string.Empty, + contract.Description ?? string.Empty, + contract.DescriptionProgress ?? string.Empty, + contract.NameNPCStart ?? string.Empty, + contract.NameNPCEnd ?? string.Empty, + contract.QuestflagStamped ?? string.Empty, + contract.QuestflagStarted ?? string.Empty, + contract.QuestflagFinished ?? string.Empty, + contract.QuestflagProgress ?? string.Empty, + contract.QuestflagTimer ?? string.Empty, + contract.QuestflagRepeatTime ?? string.Empty); + } + + return new ContractCatalog(projected.ToFrozenDictionary()); + } +} diff --git a/src/AcDream.Core/Quests/ContractEntry.cs b/src/AcDream.Core/Quests/ContractEntry.cs new file mode 100644 index 00000000..272e4057 --- /dev/null +++ b/src/AcDream.Core/Quests/ContractEntry.cs @@ -0,0 +1,73 @@ +using System.Collections.Generic; + +namespace AcDream.Core.Quests; + +/// +/// One contract's authored description, from portal.dat's ContractTable. +/// +/// +/// +/// The wire carries only an id, a stage and two timers — every word the player +/// reads comes from here, keyed by . +/// +/// +/// The Questflag* names are the server's own bookkeeping. The client +/// never reads or writes a quest flag (r10-quest-dialogs.md §1.3); it +/// keeps the names because gmContractsUI::FillProgressString @0x00498DE0 +/// branches on whether is EMPTY, which is how +/// it tells "finished for good" from "finished for now". +/// +/// +public sealed record ContractEntry( + uint Version, + uint ContractId, + string ContractName, + string Description, + /// + /// A printf format, NOT a literal — retail feeds it one integer + /// (stage - 4). The installed table has entries like + /// "%d/20 Tuskers". Rendering it verbatim shows the player a raw + /// format specifier. + /// + string DescriptionProgress, + string NameNpcStart, + string NameNpcEnd, + string QuestflagStamped, + string QuestflagStarted, + string QuestflagFinished, + string QuestflagProgress, + string QuestflagTimer, + string QuestflagRepeatTime) +{ + public static readonly ContractEntry Unknown = new( + 0u, 0u, + string.Empty, string.Empty, string.Empty, + string.Empty, string.Empty, + string.Empty, string.Empty, string.Empty, + string.Empty, string.Empty, string.Empty); +} + +/// The installed contract catalog, keyed by contract id. +public sealed class ContractCatalog(IReadOnlyDictionary contracts) +{ + public static readonly ContractCatalog Empty = + new(new Dictionary()); + + public IReadOnlyDictionary Contracts { get; } = contracts; + + public int Count => Contracts.Count; + + /// + /// The entry for , or + /// . + /// + /// + /// A miss is normal rather than exceptional: the server may track a + /// contract this client's dat build has never heard of, and the panel still + /// has to draw the row. + /// + public ContractEntry Lookup(uint contractId) => + Contracts.TryGetValue(contractId, out ContractEntry? entry) + ? entry + : ContractEntry.Unknown; +} diff --git a/src/AcDream.Core/Quests/ContractProgressText.cs b/src/AcDream.Core/Quests/ContractProgressText.cs new file mode 100644 index 00000000..29cc5b2f --- /dev/null +++ b/src/AcDream.Core/Quests/ContractProgressText.cs @@ -0,0 +1,160 @@ +using System; +using System.Globalization; +using System.Text; + +namespace AcDream.Core.Quests; + +/// +/// The contract tracker's progress column — a faithful port of +/// gmContractsUI::FillProgressString @0x00498DE0. +/// +public static class ContractProgressText +{ + private const int SecondsPerMonth = 0x278D00; // 2,592,000 — a 30-day month + private const int SecondsPerDay = 0x15180; // 86,400 + private const int SecondsPerHour = 0xE10; // 3,600 + private const int SecondsPerMinute = 0x3C; // 60 + + /// + /// Port of ClientUISystem::DeltaTimeToString @0x00565E10. + /// + /// + /// + /// Largest-unit-first, each unit omitted when zero, seconds always shown: + /// "2d 3h 4m 5s", "45s". A "month" is a flat 30 days. + /// + /// + /// Every part is emitted with a TRAILING space and the final one is then + /// truncated. That truncation is not visible in the decompiler output — + /// the instruction reads as noise — so it was settled by decoding the + /// bytes: at 0x00565F0E, mov byte ptr [esp+eax+0x1b], cl + /// with cl == 0 and eax == strlen writes the terminator over + /// buffer[len - 1]. Without it, the caller composes + /// "Done (1h 30s to Repeat)" with a double space. + /// + /// + public static string DeltaTimeToString(double seconds) + { + // Retail's _ftol2 — truncation toward zero, matching a C cast. + long total = (long)seconds; + if (total < 0) total = 0; + + long months = total / SecondsPerMonth; + long rest = total % SecondsPerMonth; + long days = rest / SecondsPerDay; + rest %= SecondsPerDay; + long hours = rest / SecondsPerHour; + rest %= SecondsPerHour; + long minutes = rest / SecondsPerMinute; + long secs = rest % SecondsPerMinute; + + var text = new StringBuilder(); + if (months != 0) Append(text, months, "mo"); + if (days != 0) Append(text, days, "d"); + if (hours != 0) Append(text, hours, "h"); + if (minutes != 0) Append(text, minutes, "m"); + Append(text, secs, "s"); + + // The trailing space the last part just wrote. + return text.ToString(0, text.Length - 1); + + static void Append(StringBuilder text, long value, string unit) + { + text.Append(value.ToString(CultureInfo.InvariantCulture)); + text.Append(unit); + text.Append(' '); + } + } + + /// + /// The progress text for one tracked contract. + /// + /// + /// The wire stage. Deliberately a raw uint rather than an enum: + /// retail encodes a progress COUNTER as 4 + n, so the values above + /// three are data, not names. + /// + /// Seconds until the repeat cooldown ends. + /// When this state arrived — the countdown anchor. + /// The authored contract, or + /// . + /// The current time. + /// + /// + /// Two details worth stating because they look like mistakes: + /// + /// + /// TimeWhenDone is never read. Only TimeWhenRepeats + /// reaches this text. The other timer is on the wire and simply does not + /// drive the progress column. + /// + /// + /// An empty QuestflagRepeatTime is what distinguishes "Done" + /// from "Available". A contract with no repeat flag is finished for + /// good; one with a repeat flag whose timer has run out is offered again. + /// + /// + public static string Build( + uint stage, + double timeWhenRepeats, + DateTime receivedAt, + ContractEntry entry, + DateTime now) + { + ArgumentNullException.ThrowIfNull(entry); + + if (stage == 1u) return "Available"; + if (stage == 2u) return "In Progress"; + + if (stage == 3u) + { + if (timeWhenRepeats <= 0d) + { + return entry.QuestflagRepeatTime.Length == 0 ? "Done" : "Available"; + } + + // Retail counts down from when the state ARRIVED, using its own + // clock — the server never sends that instant. + double elapsed = (now - receivedAt).TotalSeconds; + double remaining = timeWhenRepeats - elapsed; + if (remaining <= 0d) return "Available"; + + return $"Done ({DeltaTimeToString(remaining)} to Repeat)"; + } + + if (stage >= 4u) + { + // A counter with nothing authored to put it in. + if (entry.DescriptionProgress.Length == 0) return "In Progress"; + return FormatProgress(entry.DescriptionProgress, stage - 4u); + } + + // Stage 0 or anything else: retail returns without writing, leaving the + // caller's string as it found it. + return string.Empty; + } + + /// + /// Substitutes retail's single integer argument into an authored progress + /// format. + /// + /// + /// FillProgressString passes exactly ONE integer, so only the first + /// %d can be honoured — a second specifier would read past the + /// argument in retail too. Measured against the installed table: 89 of 322 + /// contracts author a progress format and every one uses exactly one + /// %d (e.g. "%d/20 Tuskers"), so the single-substitution + /// reading covers the whole shipped catalog rather than merely the common + /// case. + /// + private static string FormatProgress(string format, uint value) + { + int at = format.IndexOf("%d", StringComparison.Ordinal); + if (at < 0) return format; + + return string.Concat( + format.AsSpan(0, at), + value.ToString(CultureInfo.InvariantCulture), + format.AsSpan(at + 2)); + } +} diff --git a/tests/AcDream.Content.Tests/ContractTableReaderInstalledDatTests.cs b/tests/AcDream.Content.Tests/ContractTableReaderInstalledDatTests.cs new file mode 100644 index 00000000..76fa144e --- /dev/null +++ b/tests/AcDream.Content.Tests/ContractTableReaderInstalledDatTests.cs @@ -0,0 +1,103 @@ +using System; +using System.Linq; +using AcDream.Core.Quests; +using DatReaderWriter; +using DatReaderWriter.Options; + +namespace AcDream.Content.Tests; + +/// +/// Installed-DAT gate for : proves the real +/// ContractTable (portal.dat 0x0E00001D) loads through the SAME +/// production uses. Nothing read this table +/// before Campaign QT, so "does Chorizite decode it at all, or merely declare +/// the type?" was a live question — it decodes it. +/// +[Trait("Lane", "InstalledDat")] +public sealed class ContractTableReaderInstalledDatTests +{ + private static ContractCatalog Load() + { + string? datDir = ContentConformanceDats.ResolveDatDir(); + if (datDir is null) + { + Console.WriteLine("SKIP: installed retail DAT directory is unavailable."); + Assert.Fail( + "Lane=InstalledDat requires an installed retail DAT directory; " + + "see docs/release-gate.md."); + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + return ContractTableReader.Load(adapter); + } + + [Fact] + public void TheInstalledTableLoadsWithItsFullContractRoster() + { + ContractCatalog catalog = Load(); + + // 322 in the installed build. Asserted as a floor rather than an + // equality so a different dat revision is not a failure — the point is + // that the table decodes at all. + Assert.True( + catalog.Count >= 300, + $"expected the full contract roster, got {catalog.Count}"); + } + + [Fact] + public void EveryContractCarriesTheNameThePanelDraws() + { + // The wire sends only an id; if these come back empty the panel has + // nothing to show, and the failure would look like a UI bug. + ContractCatalog catalog = Load(); + + int named = catalog.Contracts.Values.Count(c => c.ContractName.Length > 0); + + Assert.True( + named > catalog.Count / 2, + $"only {named} of {catalog.Count} contracts have a name"); + } + + [Fact] + public void EveryAuthoredProgressFormatUsesExactlyOneIntegerSpecifier() + { + // ContractProgressText substitutes only the FIRST %d, because retail's + // FillProgressString passes exactly one argument. This is the + // measurement that makes that reading safe rather than merely + // convenient — if a future dat ships a format with two specifiers, the + // single-substitution port needs revisiting and this fails first. + ContractCatalog catalog = Load(); + + var offenders = catalog.Contracts.Values + .Where(c => c.DescriptionProgress.Length > 0) + .Where(c => CountSpecifiers(c.DescriptionProgress) != 1) + .Select(c => $"0x{c.ContractId:X8} \"{c.DescriptionProgress}\"") + .ToArray(); + + Assert.Empty(offenders); + } + + [Fact] + public void AMissingContractResolvesToTheUnknownEntryRatherThanThrowing() + { + // The server may track a contract this dat build has never heard of, + // and the panel still has to draw the row. + ContractCatalog catalog = Load(); + + ContractEntry entry = catalog.Lookup(0xDEADBEEFu); + + Assert.Same(ContractEntry.Unknown, entry); + } + + private static int CountSpecifiers(string format) + { + int count = 0; + for (int i = 0; i < format.Length - 1; i++) + { + if (format[i] == '%' && format[i + 1] != '%') + count++; + } + return count; + } +} diff --git a/tests/AcDream.Core.Tests/Quests/ContractProgressTextTests.cs b/tests/AcDream.Core.Tests/Quests/ContractProgressTextTests.cs new file mode 100644 index 00000000..7de63e38 --- /dev/null +++ b/tests/AcDream.Core.Tests/Quests/ContractProgressTextTests.cs @@ -0,0 +1,183 @@ +using System; +using AcDream.Core.Quests; + +namespace AcDream.Core.Tests.Quests; + +/// +/// Campaign QT slice QT4: gmContractsUI::FillProgressString @0x00498DE0 +/// and the ClientUISystem::DeltaTimeToString @0x00565E10 it calls. +/// +public sealed class ContractProgressTextTests +{ + private static readonly DateTime Arrival = new(2026, 8, 21, 12, 0, 0, DateTimeKind.Utc); + + private static ContractEntry Entry( + string descriptionProgress = "", string questflagRepeatTime = "") + => ContractEntry.Unknown with + { + DescriptionProgress = descriptionProgress, + QuestflagRepeatTime = questflagRepeatTime, + }; + + // ── DeltaTimeToString ─────────────────────────────────────────────── + + [Theory] + [InlineData(0, "0s")] + [InlineData(45, "45s")] + [InlineData(60, "1m 0s")] + [InlineData(3600, "1h 0s")] // minutes are OMITTED when zero + [InlineData(3661, "1h 1m 1s")] + [InlineData(86400, "1d 0s")] + [InlineData(2592000, "1mo 0s")] // a "month" is a flat 30 days + [InlineData(2592000 + 86400 + 3600 + 61, "1mo 1d 1h 1m 1s")] + public void DeltaTimeFormatsLargestUnitFirstAndAlwaysShowsSeconds( + double seconds, string expected) + => Assert.Equal(expected, ContractProgressText.DeltaTimeToString(seconds)); + + [Fact] + public void DeltaTimeHasNoTrailingSpace() + { + // Retail emits every part WITH a trailing space and then writes the + // terminator over the last one (0x00565F0E). Missing that truncation + // gives "Done (30s to Repeat)" with a double space — and the + // instruction is invisible in the decompiler output, so this is the + // assertion that pins the byte-level reading. + string text = ContractProgressText.DeltaTimeToString(30); + + Assert.Equal("30s", text); + Assert.DoesNotContain(" ", ContractProgressText.Build( + 3u, 30d, Arrival, Entry(questflagRepeatTime: "flag"), Arrival)); + } + + [Fact] + public void DeltaTimeTruncatesTowardZeroLikeRetailsFtol() + { + Assert.Equal("59s", ContractProgressText.DeltaTimeToString(59.99)); + } + + // ── the stage arms ────────────────────────────────────────────────── + + [Fact] + public void StageOneIsAvailable() + => Assert.Equal("Available", ContractProgressText.Build( + 1u, 0d, Arrival, Entry(), Arrival)); + + [Fact] + public void StageTwoIsInProgress() + => Assert.Equal("In Progress", ContractProgressText.Build( + 2u, 0d, Arrival, Entry(), Arrival)); + + [Fact] + public void StageThreeWithNoRepeatFlagIsDoneForGood() + { + // An empty QuestflagRepeatTime is the whole difference between a + // one-shot quest and a repeatable one on cooldown. + Assert.Equal("Done", ContractProgressText.Build( + 3u, 0d, Arrival, Entry(questflagRepeatTime: ""), Arrival)); + } + + [Fact] + public void StageThreeWithARepeatFlagAndNoTimerIsAvailableAgain() + { + Assert.Equal("Available", ContractProgressText.Build( + 3u, 0d, Arrival, Entry(questflagRepeatTime: "SomeQuestRepeat"), Arrival)); + } + + [Fact] + public void StageThreeWithATimerStillRunningCountsDownToTheRepeat() + { + string text = ContractProgressText.Build( + 3u, + timeWhenRepeats: 3661d, + Arrival, + Entry(questflagRepeatTime: "SomeQuestRepeat"), + now: Arrival); + + Assert.Equal("Done (1h 1m 1s to Repeat)", text); + } + + [Fact] + public void TheCountdownIsAnchoredAtArrivalNotRecomputedFromTheServerValue() + { + // The server sends the remaining seconds ONCE and never sends the + // instant it measured them from. Anchoring at arrival is what makes + // the timer tick; without it the same number would be shown forever. + string atArrival = ContractProgressText.Build( + 3u, 600d, Arrival, Entry(questflagRepeatTime: "f"), Arrival); + string tenMinutesLater = ContractProgressText.Build( + 3u, 600d, Arrival, Entry(questflagRepeatTime: "f"), + Arrival.AddMinutes(5)); + + Assert.Equal("Done (10m 0s to Repeat)", atArrival); + Assert.Equal("Done (5m 0s to Repeat)", tenMinutesLater); + } + + [Fact] + public void ATimerThatHasRunOutSinceArrivalReadsAsAvailable() + { + Assert.Equal("Available", ContractProgressText.Build( + 3u, 600d, Arrival, Entry(questflagRepeatTime: "f"), + now: Arrival.AddHours(1))); + } + + [Fact] + public void TimeWhenDoneNeverReachesThisText() + { + // It IS on the wire and it does NOT drive the progress column. Passing + // it here instead of TimeWhenRepeats is the plausible misreading; the + // signature refuses it, and this test says why. + string text = ContractProgressText.Build( + 3u, timeWhenRepeats: 0d, Arrival, Entry(questflagRepeatTime: ""), Arrival); + + Assert.Equal("Done", text); + } + + // ── the progress counter ──────────────────────────────────────────── + + [Theory] + [InlineData(4u, "0/20 Tuskers")] + [InlineData(9u, "5/20 Tuskers")] + [InlineData(24u, "20/20 Tuskers")] + public void StageFourAndAboveSubstitutesTheCountIntoTheAuthoredFormat( + uint stage, string expected) + { + // The count is stage - 4, and DescriptionProgress is a printf format, + // not a literal — rendering it verbatim shows the player "%d/20". + Assert.Equal(expected, ContractProgressText.Build( + stage, 0d, Arrival, Entry(descriptionProgress: "%d/20 Tuskers"), Arrival)); + } + + [Fact] + public void AProgressStageWithNoAuthoredFormatFallsBackToInProgress() + { + Assert.Equal("In Progress", ContractProgressText.Build( + 7u, 0d, Arrival, Entry(descriptionProgress: ""), Arrival)); + } + + [Fact] + public void AFormatWithoutASpecifierIsShownVerbatim() + { + Assert.Equal("Gathering herbs", ContractProgressText.Build( + 6u, 0d, Arrival, Entry(descriptionProgress: "Gathering herbs"), Arrival)); + } + + [Fact] + public void OnlyTheFirstSpecifierIsSubstitutedBecauseRetailPassesOneArgument() + { + // A second %d would read past the argument in retail too. No installed + // contract has one (measured: 89 formats, all exactly one %d), so this + // pins the behaviour rather than describing shipped content. + Assert.Equal("3 of %d", ContractProgressText.Build( + 7u, 0d, Arrival, Entry(descriptionProgress: "%d of %d"), Arrival)); + } + + [Fact] + public void AnUnknownStageProducesNothingRatherThanGuessing() + { + // Retail returns without writing, leaving the caller's string as it + // found it. Inventing a label here would put text on screen that the + // real client never shows. + Assert.Equal(string.Empty, ContractProgressText.Build( + 0u, 0d, Arrival, Entry(), Arrival)); + } +} diff --git a/tools/LayoutDump/Program.cs b/tools/LayoutDump/Program.cs index 9f819ed6..a64340d2 100644 --- a/tools/LayoutDump/Program.cs +++ b/tools/LayoutDump/Program.cs @@ -35,6 +35,55 @@ string datDir = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR") using var dats = new DatCollection(datDir, DatAccessType.Read); using var adapter = new DatCollectionAdapter(dats); +if (args.Contains("--contracts")) +{ + // Campaign QT slice QT2: what does the installed ContractTable actually + // hold, and does the reader decode it at all? + var table = dats.Get(0x0E00001Du); + if (table is null) + { + Console.WriteLine("ContractTable 0x0E00001D not found"); + return 2; + } + + Console.WriteLine($"ContractTable 0x{table.Id:X8}: {table.Contracts.Count} contracts"); + + // Which printf specifiers does the authored DescriptionProgress actually + // use? FillProgressString passes exactly ONE integer, so anything else + // would be reading past the argument in retail too. + var specs = new SortedDictionary(StringComparer.Ordinal); + int withProgress = 0; + foreach (var c in table.Contracts.Values) + { + string f = c.DescriptionProgress ?? ""; + if (f.Length == 0) continue; + withProgress++; + for (int i = 0; i < f.Length - 1; i++) + { + if (f[i] != '%') continue; + string spec = f.Substring(i, 2); + specs[spec] = specs.TryGetValue(spec, out int n) ? n + 1 : 1; + } + } + Console.WriteLine($" {withProgress} have a DescriptionProgress; specifiers:"); + foreach (var (spec, n) in specs) + Console.WriteLine($" {spec} x{n}"); + + int shown = 0; + foreach (var (key, contract) in table.Contracts.OrderBy(kv => kv.Key)) + { + if (shown++ >= 5) break; + Console.WriteLine($" 0x{key:X8} v{contract.Version} \"{contract.ContractName}\""); + Console.WriteLine($" desc: {contract.Description}"); + Console.WriteLine($" progress: {contract.DescriptionProgress}"); + Console.WriteLine($" npc: {contract.NameNPCStart} -> {contract.NameNPCEnd}"); + Console.WriteLine($" flags: started={contract.QuestflagStarted} " + + $"finished={contract.QuestflagFinished} progress={contract.QuestflagProgress} " + + $"repeat={contract.QuestflagRepeatTime}"); + } + return 0; +} + int findAt = Array.IndexOf(args, "--find"); if (findAt >= 0) { From fac2dc7248c843e37fa21d1f2e81baa06205eaea Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 14:56:46 +0200 Subject: [PATCH 31/43] =?UTF-8?q?docs(quest):=20QT5=20is=20specified=20?= =?UTF-8?q?=E2=80=94=20and=20it=20is=20the=20Journal=20panel,=20not=20a=20?= =?UTF-8?q?contract=20one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measuring the host layout rather than assuming changed what this slice is. gmContractsUI is not a panel of its own: it is tab 1 of a THREE-tab "Journal" panel at gmPanelUI slot 25, beside a notes page and a page list. Building it as a standalone window would have produced something retail does not have, and the mistake would only have surfaced at a visual gate. The other two tabs are out of scope, so the expected intermediate state is a panel with two dead tabs — recorded here so it is not filed as a defect. Everything else the page needs is now measured out of the dats: every authored label, the per-row child ids RefreshContractListbox writes, and the list's scrollbar link. The list is a UiTemplateListBox, the widget OP2 already built, so the page is binding work rather than new widget work. LayoutDump --props now resolves StringInfo through DatStringResolver instead of printing the type name, which is how the labels were read at all. Co-Authored-By: Claude Opus 5 --- .../2026-08-21-contract-tracker-campaign.md | 59 +++++++++++++++++-- tools/LayoutDump/Program.cs | 16 ++++- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/docs/plans/2026-08-21-contract-tracker-campaign.md b/docs/plans/2026-08-21-contract-tracker-campaign.md index a02b7a12..66e7623e 100644 --- a/docs/plans/2026-08-21-contract-tracker-campaign.md +++ b/docs/plans/2026-08-21-contract-tracker-campaign.md @@ -99,6 +99,49 @@ Three things a reimplementation would get wrong: 3. **`DescriptionProgress` is a printf format** taking one integer, `stage - 4`. It is not a literal string. +### It is not a "contract panel" — it is tab 1 of the JOURNAL panel + +Measured from the installed dats. Host `0x2100006E`, `gmPanelUI` slot +`0x10000559`, whose own authored `0x10000029` is **`0x19` = 25** — the same +slot-key recipe `RetailPanelCatalog` already uses for Options (10), the social +panel (12) and Map/House (16). Three tabs: + +| Tab | Caption | Page | Page type | +|---|---|---|---| +| `0x100005D3` | **Contracts** | `0x100005D4` | `0x1000004B` = `gmContractsUI` | +| `0x10000560` | **Journal** | `0x10000563` | `0x10000048` — notes: "Title:", "Notes:", "First" | +| `0x10000561` | **Page List** | — | — | + +`0x10000562` (type 1, at 276,0) is the panel's own corner button. + +Only the Contracts tab is in scope. The Journal notes page and Page List are +their own feature and are NOT part of Campaign QT — mounting the panel with two +dead tabs is the expected intermediate state, not a defect. + +### The contracts page, resolved + +Authored text read out of the dats (`LayoutDump --props`, which now resolves +`StringInfo` rather than printing the type name): + +| Element | Role | +|---|---| +| `0x100005CE` / `0x100005D6` | list column headers — "Contract" / "Status" | +| `0x100005CF` (type 5) | the list, scrollbar `0x100005D0` via property `0x72` | +| `0x100005D1` / `0x100005D2` | per-ROW children: contract name / progress text | +| `0x100005D8` → `0x100005DF` | "Status:" → value | +| `0x100005D9` → `0x100005E0` | "Contact:" → value | +| `0x100005DA` → `0x100005E1` | "Contact Location:" → value | +| `0x100005DB` → `0x100005E2` | "Quest Location:" → value | +| `0x100005DE` | description block (270x52, wrapping) | +| `0x100005DD` → `0x100005E3` | "Timed:" → value | +| `0x100005DC` | "Abandon" button | + +`gmContractsUI::RefreshContractListbox @0x00499830` walks the tracker list and, +per row, sets `0x100005D1` from the contract's name and `0x100005D2` from +`FillProgressString`, caching the result back into the row. The list is a +`UiTemplateListBox` here — the same widget OP2 built for the Options panel — so +the page is binding rather than new widget work. + ## Slices - **QT1 — wire.** Typed records + parsers for `0x0314`/`0x0315`, arrival stamp @@ -111,10 +154,18 @@ Three things a reimplementation would get wrong: Clears at generation reset. - **QT4 — the progress string.** Port `FillProgressString` + the retail `DeltaTimeToString` it calls. Table-driven tests over every stage arm. -- **QT5 — the panel.** Mount `0x21000069` by the OP3/FA recipe; list, scrollbar, - the four label/value rows, description, buttons. -- **QT6 — open/close.** Whatever raises it in retail, plus the plugin-visible - read surface from `r10-quest-dialogs.md` §11.6. +- **QT5 — the panel.** Register slot 25 in `RetailPanelCatalog`, mount the + Journal panel by the OP3/FA recipe, and bind the Contracts page: rows from + `IRuntimeContractView` x `ContractCatalog`, progress from QT4, selection + driving the detail pane. The other two tabs mount empty. +- **QT6 — open/close.** The open path (no toolbar button authors slot 25, so + it is keyboard or menu — to be measured the way FA's F3/F4 was), plus the + plugin-visible read surface from `r10-quest-dialogs.md` §11.6. + +### Landed so far + +QT1 `ab3934e2` (wire), QT3 `f629ce7f` (state + routing), QT2/QT4 `ef6b7310` +(catalog + progress string). QT5 and QT6 are open. ## Definition of done diff --git a/tools/LayoutDump/Program.cs b/tools/LayoutDump/Program.cs index a64340d2..9ddb6cdf 100644 --- a/tools/LayoutDump/Program.cs +++ b/tools/LayoutDump/Program.cs @@ -163,6 +163,8 @@ if (findAt >= 0) } } +var stringResolver = new DatStringResolver(adapter); + ElementInfo? root = ids.Length > 1 ? LayoutImporter.ImportInfos(adapter, ids[0], ids[1]) : LayoutImporter.ImportInfos(adapter, ids[0]); @@ -326,13 +328,25 @@ void Print(ElementInfo e, int depth) .OrderBy(kv => kv.Key) .Select(kv => $"0x{kv.Key:X2}={Describe(kv.Value)}")); - static string Describe(UiPropertyValue v) => v.Kind switch + string Describe(UiPropertyValue v) => v.Kind switch { UiPropertyKind.Bool => v.BoolValue.ToString(), UiPropertyKind.Integer => v.IntegerValue.ToString(), UiPropertyKind.Enum => $"0x{v.UnsignedValue:X}", + // An authored StringInfo is a table id + string id, which says + // nothing on its own -- resolve it, because "what does this + // label SAY?" is the whole reason to dump properties. + UiPropertyKind.StringInfo => DescribeString(v.StringInfoValue), _ => v.Kind.ToString(), }; + + string DescribeString(UiStringInfoValue info) + { + string? resolved = stringResolver.Resolve(info.TableId, info.StringId); + return !string.IsNullOrEmpty(resolved) + ? $"\"{resolved}\"" + : $"StringInfo(table=0x{info.TableId:X8}, id={info.StringId})"; + } Console.WriteLine($"{pad} state {stateId}: props {ids}"); } } From ec6eeb120d96faf48106b9c9aa904ace5a45dd86 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 15:12:05 +0200 Subject: [PATCH 32/43] =?UTF-8?q?feat(quest):=20QT5/QT6=20=E2=80=94=20the?= =?UTF-8?q?=20Journal=20panel,=20and=20the=20button=20that=20was=20already?= =?UTF-8?q?=20there?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quest log is on screen. Rows come from the live tracker joined to the authored catalog, the Status column runs QT4's port of FillProgressString, and the detail pane shows contact, locations, description and the other timer. Two things measured rather than assumed, each now pinned by an installed-DAT test rather than left to the commit message: The tab pairing is read from the authored 0x2E table, not inferred from x-order — the FA campaign had to correct exactly that mistake, and Contracts turns out to be the authored DEFAULT tab (0x32 = True), so opening on the wrong one would have looked like an empty panel. The open path needed no keybind at all. Toolbar button 0x1000055A authors 0x10000029 = 0x19 and has been sitting in ToolbarController.PanelButtonIds since the toolbar was ported — it just had no panel behind it, so clicking it did nothing. Registering slot 25 finished a wiring that was already three-quarters present. The list rebuild is revision-gated while the repeat countdown is not: nothing on the wire changes as a cooldown runs down, so a rebuild-gated timer would freeze on screen, and a per-frame rebuild would reset the player's scroll under them. Both directions have a test. Deliberately inert: the Abandon button (retail's abandon path is a contract-registry command this campaign did not port — authored and visible, but wiring a no-op handler would look responsive and lie), and the Journal notes and Page List tabs, which are their own feature. Campaign QT slices 5 and 6 of 6 — code-complete, connected gate owed. Co-Authored-By: Claude Opus 5 --- .../2026-08-21-contract-tracker-campaign.md | 22 +- .../InteractionRetainedUiComposition.cs | 19 + .../Layout/JournalContractsPageController.cs | 228 +++++++++++ .../UI/Layout/JournalPanelController.cs | 120 ++++++ src/AcDream.App/UI/RetailPanelCatalog.cs | 17 + src/AcDream.App/UI/RetailUiRuntime.cs | 123 ++++++ src/AcDream.App/UI/WindowNames.cs | 4 + src/AcDream.Content/ContractTableReader.cs | 5 +- src/AcDream.Core/Quests/ContractEntry.cs | 12 +- .../JournalContractsPageControllerTests.cs | 368 ++++++++++++++++++ .../UI/Layout/JournalPanelSlotProbeTests.cs | 186 +++++++++ tools/LayoutDump/Program.cs | 10 + 12 files changed, 1109 insertions(+), 5 deletions(-) create mode 100644 src/AcDream.App/UI/Layout/JournalContractsPageController.cs create mode 100644 src/AcDream.App/UI/Layout/JournalPanelController.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/JournalContractsPageControllerTests.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/JournalPanelSlotProbeTests.cs diff --git a/docs/plans/2026-08-21-contract-tracker-campaign.md b/docs/plans/2026-08-21-contract-tracker-campaign.md index 66e7623e..16588821 100644 --- a/docs/plans/2026-08-21-contract-tracker-campaign.md +++ b/docs/plans/2026-08-21-contract-tracker-campaign.md @@ -1,6 +1,7 @@ # Campaign QT — the contract tracker (H.3's client half) -**Status:** ACTIVE 2026-08-21. +**Status:** CODE-COMPLETE 2026-08-21. All six slices landed; the connected +user gate is owed. **Why now.** M4's demo scenario is "talk to an NPC, accept a quest, ... complete the quest." Everything in that sentence works today EXCEPT the player's ability @@ -162,10 +163,25 @@ the page is binding rather than new widget work. it is keyboard or menu — to be measured the way FA's F3/F4 was), plus the plugin-visible read surface from `r10-quest-dialogs.md` §11.6. -### Landed so far +### Landed QT1 `ab3934e2` (wire), QT3 `f629ce7f` (state + routing), QT2/QT4 `ef6b7310` -(catalog + progress string). QT5 and QT6 are open. +(catalog + progress string), QT5/QT6 (the panel and its open path). + +**The open path needed no new keybind.** Toolbar button `0x1000055A` authors +`0x10000029 = 0x19` and has been in `ToolbarController.PanelButtonIds` since +the toolbar was ported — it simply had no panel registered behind it, so +clicking it did nothing. Registering slot 25 completed a wiring that was +already three-quarters present. + +### Owed + +- The connected user gate: accept a quest against live ACE, open the Journal + panel, confirm the list, the progress column and a repeat countdown. +- The Abandon button is deliberately unwired — retail's abandon path is a + contract-registry command this campaign did not port. It is authored and + visible; clicking it does nothing. +- The Journal notes page and Page List tabs mount inert, by design. ## Definition of done diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index df53d351..b8d12f28 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -682,6 +682,18 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory lock (d.DatLock) chargenSkillTable = d.Dats.Get(0x0E000004u); var chargenSkillScoreResolver = new ChargenSkillScoreResolver(chargenSkillTable); + // Campaign QT slice QT5: lazily loaded on first open (the panel + // is hidden at mount), then held for the session. + AcDream.Core.Quests.ContractCatalog? contractCatalog = null; + AcDream.Core.Quests.ContractCatalog questCatalog() + { + if (contractCatalog is not null) + return contractCatalog; + lock (d.DatLock) + contractCatalog = AcDream.Content.ContractTableReader.Load(d.Dats); + return contractCatalog; + } + var bindings = new RetailUiRuntimeBindings( Host: host, Assets: assets, @@ -1007,6 +1019,13 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory CurrentCalendar: d.CurrentCalendar, PlayerCellId: () => d.PlayerController.Controller?.CellId ?? 0u, HouseLines: () => d.Runtime.HouseOwner.Lines), + // Campaign QT slice QT5. The catalog is read from the dats + // ONCE and cached: it is immutable installed content, and the + // panel would otherwise re-read a 322-entry table on every + // refresh under the shared DatLock. + Quests: new QuestRuntimeBindings( + Contracts: d.Runtime.ContractsOwner.View, + Catalog: questCatalog), StackSplitQuantity: d.StackSplitQuantity, Plugins: d.UiRegistry, Persistence: persistence, diff --git a/src/AcDream.App/UI/Layout/JournalContractsPageController.cs b/src/AcDream.App/UI/Layout/JournalContractsPageController.cs new file mode 100644 index 00000000..e148f2e4 --- /dev/null +++ b/src/AcDream.App/UI/Layout/JournalContractsPageController.cs @@ -0,0 +1,228 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Net.Messages; +using AcDream.Core.Quests; +using AcDream.Core.Ui; +using AcDream.Runtime.Gameplay; + +namespace AcDream.App.UI.Layout; + +/// +/// The Journal panel's Contracts page — retail gmContractsUI +/// (element type 0x1000004B, page 0x100005D4). +/// +/// +/// +/// A two-column list (Contract / Status) over a detail pane. Rows come from +/// — live server state — joined to the +/// authored for every word the player reads; +/// the wire itself carries only an id, a stage and two timers. +/// +/// +/// Rebuilds are revision-gated. The tracker changes rarely (accepting or +/// advancing a quest) while the panel ticks every frame, so polling +/// is what keeps this from +/// rebuilding a template list continuously. +/// +/// +public sealed class JournalContractsPageController +{ + /// The layout the row template lives in — authored property + /// 0x63 on the list's template entry. + public const uint RowTemplateLayoutId = 0x21000069u; + + /// The row template element — authored 0x62, and the id + /// retail passes to AddItemFromTemplateListByID @0x00499747. + public const uint RowTemplateElementId = 0x100005D7u; + + private const uint ListId = 0x100005CFu; + private const uint RowNameId = 0x100005D1u; + private const uint RowStatusId = 0x100005D2u; + + private const uint StatusValueId = 0x100005DFu; + private const uint ContactValueId = 0x100005E0u; + private const uint ContactLocationValueId = 0x100005E1u; + private const uint QuestLocationValueId = 0x100005E2u; + private const uint DescriptionId = 0x100005DEu; + private const uint TimedValueId = 0x100005E3u; + private const uint AbandonButtonId = 0x100005DCu; + + /// Live state and services the page reads. + /// The canonical tracker view. + /// The authored contract text. + /// + /// The clock the repeat countdown is measured against. Injected rather + /// than read from so the countdown is + /// testable without waiting for it. + /// + /// Builds one row from the authored template. + public sealed record Bindings( + IRuntimeContractView Contracts, + Func Catalog, + Func Now, + Func TemplateResolver); + + private readonly Bindings _bindings; + private readonly UiTemplateListBox? _list; + private readonly UiText? _statusValue; + private readonly UiText? _contactValue; + private readonly UiText? _contactLocationValue; + private readonly UiText? _questLocationValue; + private readonly UiText? _description; + private readonly UiText? _timedValue; + + private readonly List _rowContractIds = []; + + private long _renderedRevision = -1; + private uint _selectedContractId; + + public JournalContractsPageController(UiElement page, Bindings bindings) + { + ArgumentNullException.ThrowIfNull(page); + _bindings = bindings ?? throw new ArgumentNullException(nameof(bindings)); + + _list = UiElement.FindDescendant(page, ListId) as UiTemplateListBox; + if (_list is not null) + _list.TemplateResolver = bindings.TemplateResolver; + + _statusValue = UiElement.FindDescendant(page, StatusValueId) as UiText; + _contactValue = UiElement.FindDescendant(page, ContactValueId) as UiText; + _contactLocationValue = + UiElement.FindDescendant(page, ContactLocationValueId) as UiText; + _questLocationValue = + UiElement.FindDescendant(page, QuestLocationValueId) as UiText; + _description = UiElement.FindDescendant(page, DescriptionId) as UiText; + _timedValue = UiElement.FindDescendant(page, TimedValueId) as UiText; + + // The Abandon button has no wire message in this campaign's scope — + // retail's own abandon path is a contract-registry command we have not + // ported. Left unwired rather than given a no-op handler that would + // look responsive and do nothing. + _ = AbandonButtonId; + + Refresh(); + } + + /// The contract the detail pane is showing, or 0. + public uint SelectedContractId => _selectedContractId; + + /// Contract ids in list order, for tests. + public IReadOnlyList RowContractIds => _rowContractIds; + + public void Tick() + { + // Cheap every frame; a rebuild only when the tracker actually moved. + if (_bindings.Contracts.Snapshot.Revision != _renderedRevision) + Refresh(); + else + RefreshDetail(); // the repeat countdown ticks without a rebuild + } + + public void Refresh() + { + RuntimeContractsSnapshot snapshot = _bindings.Contracts.Snapshot; + _renderedRevision = snapshot.Revision; + + IReadOnlyList contracts = _bindings.Contracts.GetContracts(); + ContractCatalog catalog = _bindings.Catalog(); + DateTime now = _bindings.Now(); + + // Retail's own default: the server nominates a display contract, and + // otherwise the first row stands. + if (snapshot.DisplayContractId != 0u) + _selectedContractId = snapshot.DisplayContractId; + if (_selectedContractId == 0u && contracts.Count != 0) + _selectedContractId = contracts[0].ContractId; + if (contracts.Count == 0) + _selectedContractId = 0u; + + _rowContractIds.Clear(); + _list?.FlushPreservingScroll(); + + foreach (ContractTracker tracker in contracts) + { + _rowContractIds.Add(tracker.ContractId); + if (_list is null) + continue; + + UiElement? row = _list.AddItemFromTemplateList(0); + if (row is null) + continue; + + ContractEntry entry = catalog.Lookup(tracker.ContractId); + + if (UiElement.FindDescendant(row, RowNameId) is UiText name) + SetText(name, entry.ContractName); + if (UiElement.FindDescendant(row, RowStatusId) is UiText status) + { + SetText(status, ContractProgressText.Build( + (uint)tracker.Stage, tracker.TimeWhenRepeats, + tracker.ReceivedAt, entry, now)); + } + + uint captured = tracker.ContractId; + if (row is UiDatElement clickable) + clickable.OnClick = () => Select(captured); + } + + RefreshDetail(); + } + + /// Points the detail pane at one contract. + public void Select(uint contractId) + { + _selectedContractId = contractId; + RefreshDetail(); + } + + private void RefreshDetail() + { + ContractCatalog catalog = _bindings.Catalog(); + DateTime now = _bindings.Now(); + + if (_selectedContractId == 0u + || !_bindings.Contracts.TryGetContract(_selectedContractId, out ContractTracker tracker)) + { + SetText(_statusValue, string.Empty); + SetText(_contactValue, string.Empty); + SetText(_contactLocationValue, string.Empty); + SetText(_questLocationValue, string.Empty); + SetText(_description, string.Empty); + SetText(_timedValue, string.Empty); + return; + } + + ContractEntry entry = catalog.Lookup(_selectedContractId); + + SetText(_statusValue, ContractProgressText.Build( + (uint)tracker.Stage, tracker.TimeWhenRepeats, tracker.ReceivedAt, entry, now)); + SetText(_contactValue, entry.NameNpcStart); + SetText(_contactLocationValue, LocationText(entry.LocationNpcStartCell)); + SetText(_questLocationValue, LocationText(entry.LocationQuestAreaCell)); + SetText(_description, entry.Description); + + // "Timed:" is the other wire timer — the one FillProgressString never + // reads. It belongs here, not in the Status column. + SetText(_timedValue, tracker.TimeWhenDone > 0d + ? ContractProgressText.DeltaTimeToString( + Math.Max(0d, tracker.TimeWhenDone - (now - tracker.ReceivedAt).TotalSeconds)) + : string.Empty); + } + + /// + /// Coordinates, or retail's literal "Indoors" when the cell has none + /// (LandDefs::gid_to_lcoord failing, @0x0049937F). + /// + private static string LocationText(uint cellId) + { + if (cellId == 0u) return string.Empty; + return RetailPositionFormatter.FormatOutdoorCell(cellId) ?? "Indoors"; + } + + private static void SetText(UiText? text, string value) + { + if (text is null) return; + text.LinesProvider = () => [new UiText.Line(value, text.DefaultColor)]; + } +} diff --git a/src/AcDream.App/UI/Layout/JournalPanelController.cs b/src/AcDream.App/UI/Layout/JournalPanelController.cs new file mode 100644 index 00000000..cf05c8d0 --- /dev/null +++ b/src/AcDream.App/UI/Layout/JournalPanelController.cs @@ -0,0 +1,120 @@ +using System; + +namespace AcDream.App.UI.Layout; + +/// +/// Mounts retail's three-tab Journal panel — LayoutDesc +/// 0x2100006E slot 0x10000559, +/// id 25. Campaign QT +/// slice QT5, built on the OP3/FA3 tab-host recipe. +/// +/// +/// +/// The contract tracker is not a panel of its own. `gmContractsUI` is +/// tab 1 of this panel, which is why the campaign mounts a Journal rather than +/// a Contracts window. The authored tab table (property 0x2E, read from +/// the installed dats rather than inferred from x-order — the mistake Campaign +/// FA had to correct): +/// +/// +/// button 0x100005D3 ("Contracts") -> page 0x100005D4 DEFAULT (0x32 = True) +/// button 0x10000560 ("Journal") -> page 0x10000563 +/// button 0x10000561 ("Page List") -> page 0x10000564 +/// +/// +/// Only the Contracts page is in scope for Campaign QT. The Journal notes page +/// and the Page List are their own feature; mounting the panel with those two +/// tabs inert is the intended state, not a defect. +/// +/// +public sealed class JournalPanelController : IRetainedPanelController +{ + /// The floating host LayoutDesc the tab panel is resolved through. + public const uint HostLayoutId = 0x2100006Eu; + + /// + /// The Journal panel's slot within 's shared + /// gmPanelUI page stack. Its own authored 0x10000029 is + /// 0x19 = 25 — the same byte-verified slot-key recipe Options (10), + /// the social panel (12) and Map/House (16) already use. + /// + public const uint SlotElementId = 0x10000559u; + + /// The Contracts page — gmContractsUI, element type + /// 0x1000004B. + public const uint ContractsPageId = 0x100005D4u; + + /// The panel's own corner button. + private const uint CloseButtonId = 0x10000562u; + + private readonly UiTabPanel _tabPanel; + private readonly JournalContractsPageController? _contracts; + private bool _disposed; + + /// Root element of the imported panel — the tab host itself. + public UiElement Root => _tabPanel; + + public UiTabPanel TabPanel => _tabPanel; + + public JournalContractsPageController? Contracts => _contracts; + + private JournalPanelController( + UiTabPanel tabPanel, + JournalContractsPageController? contracts) + { + _tabPanel = tabPanel; + _contracts = contracts; + } + + public sealed record Callbacks( + Action Toggle, + JournalContractsPageController.Bindings Contracts); + + /// + /// Binds an imported / + /// layout to live behavior — the same "import via the host slot, then + /// Build+Bind" shape and + /// use. + /// + public static JournalPanelController? Bind(ImportedLayout layout, Callbacks callbacks) + { + ArgumentNullException.ThrowIfNull(layout); + ArgumentNullException.ThrowIfNull(callbacks); + + if (layout.Root is not UiTabPanel tabPanel) + { + Console.WriteLine( + "[D.2b] JournalPanelController.Bind: root did not build as UiTabPanel " + + $"(actual type {layout.Root.GetType().Name}) — journal panel will not open."); + return null; + } + + if (layout.FindElement(CloseButtonId) is UiButton close) + close.OnClick = callbacks.Toggle; + + JournalContractsPageController? contracts = null; + if (layout.FindElement(ContractsPageId) is { } page) + contracts = new JournalContractsPageController(page, callbacks.Contracts); + else + Console.WriteLine("[D.2b] JournalPanelController: contracts page not found."); + + return new JournalPanelController(tabPanel, contracts); + } + + /// + /// Runs the authored tab table, which activates the default entry — + /// Contracts (0x32 = True). + /// + public void ActivateTabs() => _tabPanel.ActivateTabBehavior(); + + /// Switches to the Contracts tab. + public void ShowContracts() => _tabPanel.SwitchTo(ContractsPageId); + + public void Tick() + { + if (_disposed) return; + _contracts?.Tick(); + } + + public void Dispose() => _disposed = true; +} diff --git a/src/AcDream.App/UI/RetailPanelCatalog.cs b/src/AcDream.App/UI/RetailPanelCatalog.cs index 6b9d6e0b..89f50970 100644 --- a/src/AcDream.App/UI/RetailPanelCatalog.cs +++ b/src/AcDream.App/UI/RetailPanelCatalog.cs @@ -57,6 +57,17 @@ public static class RetailPanelCatalog /// public const uint MapHouse = 16u; + /// + /// Campaign QT slice QT5: the three-tab Journal panel (Contracts / + /// Journal / Page List) — gmPanelUI slot key byte-verified from the + /// live installed DATs (host 0x2100006E slot 0x10000559's own + /// authored 0x10000029 = 0x19). Toolbar button 0x1000055A + /// authors the same id, so this one is in BOTH and + /// — like , unlike + /// . + /// + public const uint Journal = 25u; + private static readonly (uint PanelId, string WindowName)[] Mounted = { (CharacterInformation, WindowNames.CharacterInformation), @@ -71,6 +82,7 @@ public static class RetailPanelCatalog (Options, WindowNames.Options), (SocialPanel, WindowNames.SocialPanel), (MapHouse, WindowNames.MapHouse), + (Journal, WindowNames.Journal), }; private static readonly (uint PanelId, string WindowName)[] Toolbar = @@ -80,6 +92,11 @@ public static class RetailPanelCatalog (Magic, WindowNames.Spellbook), (Options, WindowNames.Options), (MapHouse, WindowNames.MapHouse), + // Campaign QT slice QT5: toolbar button 0x1000055A authors + // 0x10000029 = 0x19 and has been in ToolbarController.PanelButtonIds + // all along — it simply had no panel behind it, so clicking it did + // nothing. + (Journal, WindowNames.Journal), }; public static IReadOnlyList<(uint PanelId, string WindowName)> MountedPanels => Mounted; diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 864a9e96..3d7b22c8 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -311,6 +311,14 @@ public sealed record MapHouseRuntimeBindings( Func>? HouseLines = null, Action? HouseShown = null); +/// +/// Campaign QT slice QT5: what the Journal panel's Contracts page reads — +/// the canonical tracker view plus the authored contract text. +/// +public sealed record QuestRuntimeBindings( + AcDream.Runtime.Gameplay.IRuntimeContractView Contracts, + Func Catalog); + public sealed record InventoryRuntimeBindings( ClientObjectTable Objects, Func PlayerGuid, @@ -474,6 +482,7 @@ public sealed record RetailUiRuntimeBindings( OptionsRuntimeBindings Options, SocialRuntimeBindings Social, MapHouseRuntimeBindings MapHouse, + QuestRuntimeBindings Quests, StackSplitQuantityState StackSplitQuantity, BufferedUiRegistry? Plugins, RetailUiPersistenceBindings? Persistence, @@ -569,6 +578,7 @@ public sealed class RetailUiRuntime : IDisposable MountTooltipPresenter(); MountSocialPanel(); MountMapHousePanel(); + MountJournalPanel(); MountCharacter(); MountPlugins(); MountInventory(); @@ -692,6 +702,9 @@ public sealed class RetailUiRuntime : IDisposable public VendorUiController? VendorController { get; private set; } public OptionsPanelController? OptionsPanelController { get; private set; } public SocialPanelController? SocialPanelController { get; private set; } + + /// Campaign QT slice QT5 — the three-tab Journal panel. + public Layout.JournalPanelController? JournalPanelController { get; private set; } public MapHousePanelController? MapHousePanelController { get; private set; } internal CharacterManagementUiController? CharacterManagementController => _characterManagementMount?.Controller; @@ -873,6 +886,7 @@ public sealed class RetailUiRuntime : IDisposable SelectedObjectController?.Tick(deltaSeconds); ExternalContainerController?.Tick(); SocialPanelController?.Tick(); + JournalPanelController?.Tick(); MapHousePanelController?.Tick(deltaSeconds); _itemCooldownController?.Tick(); _characterManagementMount?.Tick(); @@ -3445,6 +3459,115 @@ public sealed class RetailUiRuntime : IDisposable Console.WriteLine("[UI] retail social panel from LayoutDesc importer (0x2100006E slot 0x1000018F)."); } + /// + /// Campaign QT slice QT5: retail's three-tab Journal panel — host + /// 0x2100006E slot 0x10000559, + /// id 25. Same import/Build/Bind + /// recipe as . The Contracts page's list has + /// ONE authored row template resolved out of a DIFFERENT layout + /// (0x21000069) than the panel itself, which is why it needs the + /// caching rather than + /// 's own path. + /// + private void MountJournalPanel() + { + ElementInfo? rootInfo; + ImportedLayout? layout; + lock (_bindings.Assets.DatLock) + { + rootInfo = LayoutImporter.ImportInfos( + _bindings.Assets.Dats, + Layout.JournalPanelController.HostLayoutId, + Layout.JournalPanelController.SlotElementId); + var resolver = new DatStringResolver(_bindings.Assets.Dats); + layout = rootInfo is null + ? null + : LayoutImporter.Build( + rootInfo, + _bindings.Assets.ResolveSprite, + _bindings.Assets.DefaultFont, + _bindings.Assets.ResolveFont, + resolver.Resolve); + } + if (rootInfo is null || layout is null) + { + Console.WriteLine( + "[UI] journal panel: LayoutDesc 0x2100006E slot 0x10000559 not found."); + return; + } + + var rowTemplates = 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; + }); + + var callbacks = new Layout.JournalPanelController.Callbacks( + Toggle: () => ToggleWindow(WindowNames.Journal), + Contracts: new Layout.JournalContractsPageController.Bindings( + Contracts: _bindings.Quests.Contracts, + Catalog: _bindings.Quests.Catalog, + Now: () => DateTime.UtcNow, + TemplateResolver: (templateLayoutId, templateElementId) => + { + lock (_bindings.Assets.DatLock) + return rowTemplates.Resolve(templateLayoutId, templateElementId); + })); + + Layout.JournalPanelController? controller; + lock (_bindings.Assets.DatLock) + controller = Layout.JournalPanelController.Bind(layout, callbacks); + if (controller is null) + { + Console.WriteLine("[UI] journal panel: required root did not build as UiTabPanel."); + return; + } + + controller.ActivateTabs(); + JournalPanelController = controller; + + RetailWindowHandle handle = RetailWindowFrame.Mount( + Host.Root, + controller.Root, + _bindings.Assets.ResolveSprite, + new RetailWindowFrame.Options + { + WindowName = WindowNames.Journal, + Chrome = RetailWindowChrome.NineSlice, + Left = 230f, + Top = 160f, + Visible = false, + ResizeX = false, + ResizeY = true, + ResizableEdges = ResizeEdges.Bottom, + ConstrainDragToParent = true, + ConstrainResizeToParent = true, + ContentAnchors = AnchorEdges.Left | AnchorEdges.Top + | AnchorEdges.Right | AnchorEdges.Bottom, + ContentClickThrough = false, + DrawChromeCenter = !AuthorsFullPanelCenter(rootInfo), + Controller = controller, + }); + _panelUi.RegisterMainPanel( + RetailPanelCatalog.Journal, + WindowNames.Journal, + handle, + rootInfo.TryGetEffectiveBool( + RetailPanelUiController.RestorePreviousPropertyId, + out bool restorePrevious) + && restorePrevious); + Console.WriteLine( + "[UI] retail journal panel from LayoutDesc importer (0x2100006E slot 0x10000559)."); + } + /// /// Batch C (overnight hover/UI round, 2026-08-17): the two-tab Map/House /// panel — host 0x2100006E slot 0x1000018C, diff --git a/src/AcDream.App/UI/WindowNames.cs b/src/AcDream.App/UI/WindowNames.cs index 1d11d7e5..f641c7af 100644 --- a/src/AcDream.App/UI/WindowNames.cs +++ b/src/AcDream.App/UI/WindowNames.cs @@ -44,4 +44,8 @@ public static class WindowNames /// Batch C (overnight hover/UI round): the two-tab Map/House /// panel (). public const string MapHouse = "map-house"; + + /// Campaign QT slice QT5: the three-tab Contracts/Journal/Page + /// List panel (). + public const string Journal = "journal"; } diff --git a/src/AcDream.Content/ContractTableReader.cs b/src/AcDream.Content/ContractTableReader.cs index 9c7f87d7..58c1a4dd 100644 --- a/src/AcDream.Content/ContractTableReader.cs +++ b/src/AcDream.Content/ContractTableReader.cs @@ -61,7 +61,10 @@ public static class ContractTableReader contract.QuestflagFinished ?? string.Empty, contract.QuestflagProgress ?? string.Empty, contract.QuestflagTimer ?? string.Empty, - contract.QuestflagRepeatTime ?? string.Empty); + contract.QuestflagRepeatTime ?? string.Empty, + contract.LocationNPCStart?.CellId ?? 0u, + contract.LocationNPCEnd?.CellId ?? 0u, + contract.LocationQuestArea?.CellId ?? 0u); } return new ContractCatalog(projected.ToFrozenDictionary()); diff --git a/src/AcDream.Core/Quests/ContractEntry.cs b/src/AcDream.Core/Quests/ContractEntry.cs index 272e4057..a2f486e9 100644 --- a/src/AcDream.Core/Quests/ContractEntry.cs +++ b/src/AcDream.Core/Quests/ContractEntry.cs @@ -37,7 +37,17 @@ public sealed record ContractEntry( string QuestflagFinished, string QuestflagProgress, string QuestflagTimer, - string QuestflagRepeatTime) + string QuestflagRepeatTime, + /// + /// Landcell of the NPC who offers the contract. The panel's "Contact + /// Location" row shows this as coordinates, or "Indoors" when the cell has + /// no outdoor coordinates + /// (LandDefs::gid_to_lcoord failing, @0x0049937F). + /// + uint LocationNpcStartCell = 0u, + uint LocationNpcEndCell = 0u, + /// Landcell of the quest area — the "Quest Location" row. + uint LocationQuestAreaCell = 0u) { public static readonly ContractEntry Unknown = new( 0u, 0u, diff --git a/tests/AcDream.App.Tests/UI/Layout/JournalContractsPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/JournalContractsPageControllerTests.cs new file mode 100644 index 00000000..49b20d81 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/JournalContractsPageControllerTests.cs @@ -0,0 +1,368 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Core.Net.Messages; +using AcDream.Core.Quests; +using AcDream.Runtime.Gameplay; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Campaign QT slice QT5: the Journal panel's Contracts page. +/// +public sealed class JournalContractsPageControllerTests +{ + private static readonly DateTime Now = new(2026, 8, 21, 12, 0, 0, DateTimeKind.Utc); + + // The authored ids, from the installed dats. + private const uint ListId = 0x100005CFu; + private const uint RowNameId = 0x100005D1u; + private const uint RowStatusId = 0x100005D2u; + private const uint StatusValueId = 0x100005DFu; + private const uint ContactValueId = 0x100005E0u; + private const uint ContactLocationValueId = 0x100005E1u; + private const uint QuestLocationValueId = 0x100005E2u; + private const uint DescriptionId = 0x100005DEu; + private const uint TimedValueId = 0x100005E3u; + + private static UiText Text(uint id) => new() + { + DatElementId = id, + Width = 200f, + Height = 18f, + }; + + /// + /// A page carrying the same child ids the real one does, so the controller + /// resolves exactly what production resolves. + /// + private static (UiElement Page, UiTemplateListBox List) BuildPage() + { + var listInfo = new ElementInfo + { + Id = ListId, Type = 5, Width = 270, Height = 298, + }; + var list = new UiTemplateListBox( + listInfo, + static _ => (0u, 0, 0), + [new UiTemplateListEntry( + JournalContractsPageController.RowTemplateLayoutId, + JournalContractsPageController.RowTemplateElementId)], + scrollbarElementId: 0u) + { + // LayoutImporter.Build sets this in production; a directly + // constructed widget has it at 0 and FindDescendant never sees it. + DatElementId = ListId, + }; + + var page = new UiPanel { Width = 300f, Height = 500f }; + page.AddChild(list); + foreach (uint id in new[] + { + StatusValueId, ContactValueId, ContactLocationValueId, + QuestLocationValueId, DescriptionId, TimedValueId, + }) + { + page.AddChild(Text(id)); + } + + return (page, list); + } + + /// One row: a name text and a status text, as retail authors. + private static UiElement? RowTemplate(uint layoutId, uint elementId) + { + if (layoutId != JournalContractsPageController.RowTemplateLayoutId + || elementId != JournalContractsPageController.RowTemplateElementId) + { + return null; + } + + var row = new UiPanel { Width = 270f, Height = 16f }; + row.AddChild(Text(RowNameId)); + row.AddChild(Text(RowStatusId)); + return row; + } + + private static ContractCatalog Catalog(params ContractEntry[] entries) + => new(entries.ToDictionary(e => e.ContractId)); + + private static ContractEntry Entry( + uint id, + string name, + string description = "", + string contact = "", + string progressFormat = "", + string repeatFlag = "", + uint contactCell = 0u, + uint questCell = 0u) + => ContractEntry.Unknown with + { + ContractId = id, + ContractName = name, + Description = description, + NameNpcStart = contact, + DescriptionProgress = progressFormat, + QuestflagRepeatTime = repeatFlag, + LocationNpcStartCell = contactCell, + LocationQuestAreaCell = questCell, + }; + + private static JournalContractsPageController Bind( + UiElement page, + RuntimeContractState state, + ContractCatalog catalog, + DateTime? now = null) + => new(page, new JournalContractsPageController.Bindings( + Contracts: state.View, + Catalog: () => catalog, + Now: () => now ?? Now, + TemplateResolver: RowTemplate)); + + private static void Track( + RuntimeContractState state, + uint contractId, + uint stage, + double whenRepeats = 0d, + double whenDone = 0d, + bool setAsDisplay = false) + => state.ApplyUpdate(new ContractTrackerUpdate( + new ContractTracker(1u, contractId, (ContractStage)stage, whenDone, whenRepeats, Now), + Delete: false, + SetAsDisplay: setAsDisplay)); + + private static string TextOf(UiElement page, uint id) + { + var text = UiElement.FindDescendant(page, id) as UiText; + return text?.LinesProvider?.Invoke().FirstOrDefault().Text ?? string.Empty; + } + + [Fact] + public void RowsCarryTheAuthoredNameAndTheRetailProgressText() + { + // The wire sends only an id and a stage; the name comes from the dat + // and the status from FillProgressString. Getting either from the + // wrong source is the failure this pins. + (UiElement page, _) = BuildPage(); + using var state = new RuntimeContractState(); + Track(state, 0x10u, stage: 2u); + + JournalContractsPageController page1 = Bind( + page, state, Catalog(Entry(0x10u, "Aerlinthe Recall Ring"))); + + Assert.Equal(new[] { 0x10u }, page1.RowContractIds.ToArray()); + UiText name = Assert.IsType(Assert.Single( + UiElement.FindDescendant(page, ListId)!.Children.SelectMany(Flatten), + e => (e as UiText)?.DatElementId == RowNameId)); + Assert.Equal("Aerlinthe Recall Ring", name.LinesProvider!()[0].Text); + } + + private static IEnumerable Flatten(UiElement e) + { + yield return e; + foreach (UiElement child in e.Children) + { + foreach (UiElement descendant in Flatten(child)) + yield return descendant; + } + } + + [Fact] + public void TheDetailPaneShowsTheSelectedContract() + { + (UiElement page, _) = BuildPage(); + using var state = new RuntimeContractState(); + Track(state, 0x10u, stage: 2u); + Track(state, 0x20u, stage: 1u); + + JournalContractsPageController controller = Bind(page, state, Catalog( + Entry(0x10u, "First", description: "Kill the thing.", contact: "Bob"), + Entry(0x20u, "Second", description: "Find the other thing.", contact: "Alice"))); + + controller.Select(0x20u); + + Assert.Equal("Find the other thing.", TextOf(page, DescriptionId)); + Assert.Equal("Alice", TextOf(page, ContactValueId)); + Assert.Equal("Available", TextOf(page, StatusValueId)); + } + + [Fact] + public void TheServersDisplayContractIsWhatOpensSelected() + { + // SetAsDisplayContract is the server nominating what to show. Ignoring + // it and always selecting the first row would show the wrong quest + // right after the one the player just accepted. + (UiElement page, _) = BuildPage(); + using var state = new RuntimeContractState(); + Track(state, 0x10u, stage: 2u); + Track(state, 0x20u, stage: 2u, setAsDisplay: true); + + JournalContractsPageController controller = Bind(page, state, Catalog( + Entry(0x10u, "First"), Entry(0x20u, "Second"))); + + Assert.Equal(0x20u, controller.SelectedContractId); + } + + [Fact] + public void WithNoDisplayContractTheFirstRowStands() + { + (UiElement page, _) = BuildPage(); + using var state = new RuntimeContractState(); + Track(state, 0x30u, stage: 2u); + Track(state, 0x10u, stage: 2u); + + JournalContractsPageController controller = Bind(page, state, Catalog( + Entry(0x10u, "First"), Entry(0x30u, "Third"))); + + // GetContracts orders by id, so 0x10 is first. + Assert.Equal(0x10u, controller.SelectedContractId); + } + + [Fact] + public void AnEmptyTrackerClearsTheDetailPaneRatherThanStrandingText() + { + (UiElement page, _) = BuildPage(); + using var state = new RuntimeContractState(); + Track(state, 0x10u, stage: 2u); + + JournalContractsPageController controller = Bind( + page, state, Catalog(Entry(0x10u, "First", description: "Kill it."))); + Assert.Equal("Kill it.", TextOf(page, DescriptionId)); + + state.ApplyTable(new Dictionary()); + controller.Tick(); + + Assert.Equal(0u, controller.SelectedContractId); + Assert.Equal(string.Empty, TextOf(page, DescriptionId)); + Assert.Empty(controller.RowContractIds); + } + + [Fact] + public void TheListRebuildsOnlyWhenTheTrackerActuallyMoved() + { + // Tick runs every frame while the tracker changes rarely. A rebuild + // per frame would re-Build a template row list continuously and reset + // the player's scroll under them. + (UiElement page, _) = BuildPage(); + using var state = new RuntimeContractState(); + int builds = 0; + + var controller = new JournalContractsPageController( + page, + new JournalContractsPageController.Bindings( + Contracts: state.View, + Catalog: () => Catalog(Entry(0x10u, "First")), + Now: () => Now, + TemplateResolver: (l, e) => { builds++; return RowTemplate(l, e); })); + + Track(state, 0x10u, stage: 2u); + controller.Tick(); + int afterFirstChange = builds; + + for (int frame = 0; frame < 10; frame++) + controller.Tick(); + + Assert.Equal(afterFirstChange, builds); + } + + [Fact] + public void AContractTheDatHasNeverHeardOfStillGetsARow() + { + // The server can track a contract this dat build does not carry. The + // row must still appear — dropping it would hide a live quest. + (UiElement page, _) = BuildPage(); + using var state = new RuntimeContractState(); + Track(state, 0xDEADu, stage: 2u); + + JournalContractsPageController controller = + Bind(page, state, ContractCatalog.Empty); + + Assert.Equal(new[] { 0xDEADu }, controller.RowContractIds.ToArray()); + Assert.Equal("In Progress", TextOf(page, StatusValueId)); + } + + [Fact] + public void ALocationWithNoOutdoorCoordinatesReadsAsIndoors() + { + // Retail's literal string when LandDefs::gid_to_lcoord fails + // (@0x0049937F). An indoor cell must not render as blank or as raw + // numbers. + (UiElement page, _) = BuildPage(); + using var state = new RuntimeContractState(); + Track(state, 0x10u, stage: 2u); + + Bind(page, state, Catalog(Entry(0x10u, "First", contactCell: 0x01020304u))); + + Assert.Equal("Indoors", TextOf(page, ContactLocationValueId)); + } + + [Fact] + public void AnUnsetLocationIsBlankRatherThanIndoors() + { + // A contract that authors no location at all is different from one + // whose location is inside a dungeon. + (UiElement page, _) = BuildPage(); + using var state = new RuntimeContractState(); + Track(state, 0x10u, stage: 2u); + + Bind(page, state, Catalog(Entry(0x10u, "First"))); + + Assert.Equal(string.Empty, TextOf(page, QuestLocationValueId)); + } + + [Fact] + public void TheTimedRowUsesTimeWhenDoneNotTimeWhenRepeats() + { + // The two wire timers mean different things and land in different + // places: TimeWhenRepeats drives the Status column, TimeWhenDone this + // row. Swapping them shows a plausible-looking wrong number. + (UiElement page, _) = BuildPage(); + using var state = new RuntimeContractState(); + Track(state, 0x10u, stage: 2u, whenDone: 3661d, whenRepeats: 90d); + + Bind(page, state, Catalog(Entry(0x10u, "First"))); + + Assert.Equal("1h 1m 1s", TextOf(page, TimedValueId)); + } + + [Fact] + public void TheRepeatCountdownTicksWithoutRebuildingTheList() + { + // Nothing on the wire changes while a cooldown runs down, so a + // revision-gated rebuild alone would freeze the timer on screen. + (UiElement page, _) = BuildPage(); + using var state = new RuntimeContractState(); + Track(state, 0x10u, stage: 3u, whenRepeats: 600d); + + DateTime now = Now; + var controller = new JournalContractsPageController( + page, + new JournalContractsPageController.Bindings( + Contracts: state.View, + Catalog: () => Catalog(Entry(0x10u, "First", repeatFlag: "f")), + Now: () => now, + TemplateResolver: RowTemplate)); + + Assert.Equal("Done (10m 0s to Repeat)", TextOf(page, StatusValueId)); + + now = Now.AddMinutes(5); + controller.Tick(); + + Assert.Equal("Done (5m 0s to Repeat)", TextOf(page, StatusValueId)); + } + + [Fact] + public void AProgressCounterRendersThroughTheAuthoredFormat() + { + (UiElement page, _) = BuildPage(); + using var state = new RuntimeContractState(); + Track(state, 0x10u, stage: 9u); // ProgressCounter + 5 + + Bind(page, state, Catalog( + Entry(0x10u, "First", progressFormat: "%d/20 Tuskers"))); + + Assert.Equal("5/20 Tuskers", TextOf(page, StatusValueId)); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/JournalPanelSlotProbeTests.cs b/tests/AcDream.App.Tests/UI/Layout/JournalPanelSlotProbeTests.cs new file mode 100644 index 00000000..582c992d --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/JournalPanelSlotProbeTests.cs @@ -0,0 +1,186 @@ +using System; +using System.IO; +using System.Linq; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Content; +using DatReaderWriter; +using DatReaderWriter.Options; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Campaign QT slice QT5: pins the authored facts the Journal panel is built +/// on, against the live installed DATs rather than a committed fixture. +/// +/// +/// These are the facts a reader would otherwise have to take on trust from a +/// commit message: the panel's slot key, its three-tab table, and which tab is +/// the authored default. The FA campaign had to CORRECT a tab pairing that had +/// been inferred from x-order, which is why the pairing is asserted here from +/// the authored 0x2E table itself. +/// +[Trait("Lane", "InstalledDat")] +public sealed class JournalPanelSlotProbeTests +{ + private const uint SlotKeyPropertyId = 0x10000029u; + private const uint TabTablePropertyId = 0x2Eu; + private const uint TabButtonPropertyId = 0x30u; + private const uint TabPagePropertyId = 0x31u; + private const uint TabDefaultPropertyId = 0x32u; + + private const uint ToolbarLayoutId = 0x21000016u; + private const uint JournalToolbarButtonId = 0x1000055Au; + + private static ElementInfo Import(uint layoutId, uint rootElementId = 0u) + { + string? datDir = ContentConformanceDatDir(); + 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); + using var adapter = new DatCollectionAdapter(dats); + ElementInfo? info = rootElementId == 0u + ? LayoutImporter.ImportInfos(adapter, layoutId) + : LayoutImporter.ImportInfos(adapter, layoutId, rootElementId); + Assert.NotNull(info); + return info!; + } + + private static string? ContentConformanceDatDir() + { + string? fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv)) + return fromEnv; + + string def = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + return Directory.Exists(def) ? def : null; + } + + private static UiPropertyValue? Property(ElementInfo info, uint propertyId) + { + foreach (UiStateInfo state in info.States.Values) + { + if (state.Properties.Values.TryGetValue(propertyId, out UiPropertyValue? value)) + return value; + } + return null; + } + + [Fact] + public void TheSlotAuthorsTheCatalogPanelId() + { + ElementInfo slot = Import( + JournalPanelController.HostLayoutId, + JournalPanelController.SlotElementId); + + UiPropertyValue? key = Property(slot, SlotKeyPropertyId); + + Assert.NotNull(key); + Assert.Equal(RetailPanelCatalog.Journal, (uint)key!.UnsignedValue); + } + + [Fact] + public void TheAuthoredTabTablePairsContractsWithTheGmContractsUiPage() + { + // Read from the table, never inferred from x-order. + ElementInfo slot = Import( + JournalPanelController.HostLayoutId, + JournalPanelController.SlotElementId); + + UiPropertyValue? tabs = Property(slot, TabTablePropertyId); + Assert.NotNull(tabs); + Assert.Equal(3, tabs!.ArrayValue.Count); + + UiPropertyValue first = tabs.ArrayValue[0]; + Assert.Equal( + 0x100005D3u, + (uint)first.StructValue[TabButtonPropertyId].UnsignedValue); + Assert.Equal( + JournalPanelController.ContractsPageId, + (uint)first.StructValue[TabPagePropertyId].UnsignedValue); + } + + [Fact] + public void ContractsIsTheAuthoredDefaultTab() + { + // Opening on the wrong tab would look like the panel is empty. + ElementInfo slot = Import( + JournalPanelController.HostLayoutId, + JournalPanelController.SlotElementId); + + UiPropertyValue tabs = Property(slot, TabTablePropertyId)!; + + UiPropertyValue defaultTab = Assert.Single( + tabs.ArrayValue, + t => t.StructValue.TryGetValue(TabDefaultPropertyId, out UiPropertyValue? d) + && d.BoolValue); + + Assert.Equal( + JournalPanelController.ContractsPageId, + (uint)defaultTab.StructValue[TabPagePropertyId].UnsignedValue); + } + + [Fact] + public void TheToolbarButtonAuthorsTheSamePanelId() + { + // The button was already in ToolbarController.PanelButtonIds with no + // panel behind it, so clicking it did nothing. This is the fact that + // makes it work rather than a keybind. + ElementInfo toolbar = Import(ToolbarLayoutId); + + ElementInfo? button = Find(toolbar, JournalToolbarButtonId); + Assert.NotNull(button); + + UiPropertyValue? key = Property(button!, SlotKeyPropertyId); + Assert.NotNull(key); + Assert.Equal(RetailPanelCatalog.Journal, (uint)key!.UnsignedValue); + } + + [Fact] + public void TheContractsPageCarriesEveryChildTheControllerResolves() + { + // A renamed or missing child would silently leave a blank row rather + // than fail, because the controller binds defensively. + ElementInfo slot = Import( + JournalPanelController.HostLayoutId, + JournalPanelController.SlotElementId); + + ElementInfo page = Assert.IsType( + Find(slot, JournalPanelController.ContractsPageId)); + + foreach (uint childId in new[] + { + 0x100005CFu, // the list + 0x100005DEu, // description + 0x100005DFu, // status value + 0x100005E0u, // contact value + 0x100005E1u, // contact location + 0x100005E2u, // quest location + 0x100005E3u, // timed value + 0x100005DCu, // Abandon + }) + { + Assert.True( + Find(page, childId) is not null, + $"contracts page is missing authored child 0x{childId:X8}"); + } + } + + private static ElementInfo? Find(ElementInfo root, uint id) + { + if (root.Id == id) return root; + foreach (ElementInfo child in root.Children) + { + ElementInfo? hit = Find(child, id); + if (hit is not null) return hit; + } + return null; + } +} diff --git a/tools/LayoutDump/Program.cs b/tools/LayoutDump/Program.cs index 9ddb6cdf..8b72528c 100644 --- a/tools/LayoutDump/Program.cs +++ b/tools/LayoutDump/Program.cs @@ -333,10 +333,20 @@ void Print(ElementInfo e, int depth) UiPropertyKind.Bool => v.BoolValue.ToString(), UiPropertyKind.Integer => v.IntegerValue.ToString(), UiPropertyKind.Enum => $"0x{v.UnsignedValue:X}", + UiPropertyKind.DataId => $"did:0x{v.UnsignedValue:X8}", // An authored StringInfo is a table id + string id, which says // nothing on its own -- resolve it, because "what does this // label SAY?" is the whole reason to dump properties. UiPropertyKind.StringInfo => DescribeString(v.StringInfoValue), + // A tab table (0x2E) is an array of structs pairing a button + // id with its page id. Printing "Array" hides the one thing it + // is for -- and inferring the pairing from x-order instead is + // exactly the mistake Campaign FA had to correct. + UiPropertyKind.Array => "[" + string.Join( + ", ", v.ArrayValue.Select(Describe)) + "]", + UiPropertyKind.Struct => "{" + string.Join( + ", ", v.StructValue.OrderBy(kv => kv.Key) + .Select(kv => $"0x{kv.Key:X2}={Describe(kv.Value)}")) + "}", _ => v.Kind.ToString(), }; From 56beeb720dc35405dfa5f59baea321ddff8d7326 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 15:17:14 +0200 Subject: [PATCH 33/43] =?UTF-8?q?fix(quest):=20QT5=20=E2=80=94=20the=20row?= =?UTF-8?q?s=20were=20unclickable,=20and=20nothing=20showed=20which=20was?= =?UTF-8?q?=20selected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in the panel as committed, both found by checking the code against the authored data rather than by a test. The authored row template is a Type-3 generic container, which resolves through DatWidgetFactory's fallback arm to UiDatElement — whose constructor sets ClickThrough = true ("generic decoration; behavioral widgets opt back in"). Binding OnClick without clearing that compiles, reads correctly, and produces a list in which nothing can be selected: every click sails past the row. The skills page had already met this and left the precedent; I did not follow it. And there was no selection highlight at all, so even once clicking worked the player could not tell which row the detail pane was describing. UiTemplateListBox has no selection mechanism of its own, so the page opts in directly and re-PAINTS the highlight after a rebuild — a rebuild discards the row objects, so remembering the selection is not enough to keep it visible. The tests for both initially passed while the bugs were live, because the fixture's row root was a UiPanel and its text started white. A UiPanel is not click-through, so the first test was vacuous; white-on-white made the highlight unobservable. The fixture now builds the same UiDatElement production does and authors a non-white colour. This is the third time this campaign a fixture that did not match the real widget hid a real defect. Live mount confirmed against the installed dats in this session's client run: "[UI] retail journal panel from LayoutDesc importer (0x2100006E slot 0x10000559)" with no bind failure. Co-Authored-By: Claude Opus 5 --- .../Layout/JournalContractsPageController.cs | 42 +++++- .../JournalContractsPageControllerTests.cs | 136 +++++++++++++++++- 2 files changed, 175 insertions(+), 3 deletions(-) diff --git a/src/AcDream.App/UI/Layout/JournalContractsPageController.cs b/src/AcDream.App/UI/Layout/JournalContractsPageController.cs index e148f2e4..1791ec74 100644 --- a/src/AcDream.App/UI/Layout/JournalContractsPageController.cs +++ b/src/AcDream.App/UI/Layout/JournalContractsPageController.cs @@ -72,7 +72,16 @@ public sealed class JournalContractsPageController private readonly UiText? _description; private readonly UiText? _timedValue; + /// + /// Retail's own selection highlight for a listbox row. UiTemplateListBox + /// has no generic selection mechanism (its class doc), so the page opts in + /// directly — the same precedent + /// set. + /// + private static readonly Vector4 SelectedNameColor = Vector4.One; + private readonly List _rowContractIds = []; + private readonly List<(uint ContractId, UiText? Name, Vector4 Unselected)> _rows = []; private long _renderedRevision = -1; private uint _selectedContractId; @@ -138,6 +147,7 @@ public sealed class JournalContractsPageController _selectedContractId = 0u; _rowContractIds.Clear(); + _rows.Clear(); _list?.FlushPreservingScroll(); foreach (ContractTracker tracker in contracts) @@ -152,7 +162,8 @@ public sealed class JournalContractsPageController ContractEntry entry = catalog.Lookup(tracker.ContractId); - if (UiElement.FindDescendant(row, RowNameId) is UiText name) + var name = UiElement.FindDescendant(row, RowNameId) as UiText; + if (name is not null) SetText(name, entry.ContractName); if (UiElement.FindDescendant(row, RowStatusId) is UiText status) { @@ -161,11 +172,23 @@ public sealed class JournalContractsPageController tracker.ReceivedAt, entry, now)); } + // Captured AFTER SetText, which never touches DefaultColor (it is + // read lazily inside the provider closure), so this is the row's + // own authored colour to restore on deselect. + _rows.Add((tracker.ContractId, name, name?.DefaultColor ?? Vector4.One)); + uint captured = tracker.ContractId; if (row is UiDatElement clickable) + { + // A generic Type-3 container is click-THROUGH by default, so + // without this the handler below never fires and the list looks + // dead. UiDatElement carries the opt-in seam for exactly this. + clickable.ClickThrough = false; clickable.OnClick = () => Select(captured); + } } + ApplySelectionHighlight(); RefreshDetail(); } @@ -173,9 +196,26 @@ public sealed class JournalContractsPageController public void Select(uint contractId) { _selectedContractId = contractId; + ApplySelectionHighlight(); RefreshDetail(); } + /// + /// Re-painted rather than merely remembered: a rebuild discards the old row + /// objects, so a preserved selection has to be applied to the new ones. + /// + private void ApplySelectionHighlight() + { + foreach ((uint contractId, UiText? name, Vector4 unselected) in _rows) + { + if (name is not null) + { + name.DefaultColor = + contractId == _selectedContractId ? SelectedNameColor : unselected; + } + } + } + private void RefreshDetail() { ContractCatalog catalog = _bindings.Catalog(); diff --git a/tests/AcDream.App.Tests/UI/Layout/JournalContractsPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/JournalContractsPageControllerTests.cs index 49b20d81..cfd1587e 100644 --- a/tests/AcDream.App.Tests/UI/Layout/JournalContractsPageControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/JournalContractsPageControllerTests.cs @@ -27,11 +27,20 @@ public sealed class JournalContractsPageControllerTests private const uint DescriptionId = 0x100005DEu; private const uint TimedValueId = 0x100005E3u; + /// + /// The authored row name/status colour. Deliberately NOT white: the + /// selection highlight paints white, so a fixture that starts white cannot + /// observe the highlight at all. + /// + private static readonly System.Numerics.Vector4 AuthoredTextColor = + new(0.8f, 0.8f, 0.8f, 1f); + private static UiText Text(uint id) => new() { DatElementId = id, Width = 200f, Height = 18f, + DefaultColor = AuthoredTextColor, }; /// @@ -71,7 +80,15 @@ public sealed class JournalContractsPageControllerTests return (page, list); } - /// One row: a name text and a status text, as retail authors. + /// + /// One row: a name text and a status text, as retail authors. + /// + /// + /// The root is a because that is what the real + /// Type-3 template resolves to through DatWidgetFactory's generic fallback + /// arm — and its click-through default is the entire point of one of the + /// tests below. A UiPanel here would make that test pass vacuously. + /// private static UiElement? RowTemplate(uint layoutId, uint elementId) { if (layoutId != JournalContractsPageController.RowTemplateLayoutId @@ -80,7 +97,15 @@ public sealed class JournalContractsPageControllerTests return null; } - var row = new UiPanel { Width = 270f, Height = 16f }; + var row = new UiDatElement( + new ElementInfo + { + Id = JournalContractsPageController.RowTemplateElementId, + Type = 3, + Width = 270, + Height = 20, + }, + static _ => (0u, 0, 0)); row.AddChild(Text(RowNameId)); row.AddChild(Text(RowStatusId)); return row; @@ -353,6 +378,113 @@ public sealed class JournalContractsPageControllerTests Assert.Equal("Done (5m 0s to Repeat)", TextOf(page, StatusValueId)); } + [Fact] + public void RowsOptOutOfClickThroughOrTheListIsDead() + { + // The authored row template is a Type-3 generic container, and those + // are click-THROUGH by default — the click sails past the row to + // whatever is behind it and the handler never runs. Binding OnClick + // without clearing ClickThrough compiles, looks right, and produces a + // list you cannot select anything in. + (UiElement page, _) = BuildPage(); + using var state = new RuntimeContractState(); + Track(state, 0x10u, stage: 2u); + + Bind(page, state, Catalog(Entry(0x10u, "First"))); + + UiDatElement row = Assert.IsAssignableFrom(Assert.Single( + UiElement.FindDescendant(page, ListId)!.Children.SelectMany(Flatten), + e => e is UiDatElement { OnClick: not null })); + Assert.False(row.ClickThrough); + } + + [Fact] + public void ClickingARowSelectsIt() + { + (UiElement page, _) = BuildPage(); + using var state = new RuntimeContractState(); + Track(state, 0x10u, stage: 2u); + Track(state, 0x20u, stage: 2u); + + JournalContractsPageController controller = Bind(page, state, Catalog( + Entry(0x10u, "First"), Entry(0x20u, "Second", description: "Second desc."))); + Assert.Equal(0x10u, controller.SelectedContractId); + + UiDatElement second = Assert.IsAssignableFrom( + UiElement.FindDescendant(page, ListId)!.Children + .SelectMany(Flatten) + .Where(e => e is UiDatElement { OnClick: not null }) + .ElementAt(1)); + second.OnClick!(); + + Assert.Equal(0x20u, controller.SelectedContractId); + Assert.Equal("Second desc.", TextOf(page, DescriptionId)); + } + + [Fact] + public void TheSelectedRowIsHighlightedAndTheOthersAreNot() + { + // Without this the player cannot tell which row the detail pane is + // describing. + (UiElement page, _) = BuildPage(); + using var state = new RuntimeContractState(); + Track(state, 0x10u, stage: 2u); + Track(state, 0x20u, stage: 2u); + + JournalContractsPageController controller = Bind(page, state, Catalog( + Entry(0x10u, "First"), Entry(0x20u, "Second"))); + + UiText[] names = UiElement.FindDescendant(page, ListId)!.Children + .SelectMany(Flatten) + .OfType() + .Where(t => t.DatElementId == RowNameId) + .ToArray(); + Assert.Equal(2, names.Length); + + // Row 0 opens selected (no display contract, so the first row stands). + System.Numerics.Vector4 selectedColor = names[0].DefaultColor; + System.Numerics.Vector4 unselectedColor = names[1].DefaultColor; + Assert.NotEqual(unselectedColor, selectedColor); + + controller.Select(0x20u); + + // The highlight MOVES: the old row goes back to its authored colour + // rather than both rows ending up lit. + Assert.Equal(unselectedColor, names[0].DefaultColor); + Assert.Equal(selectedColor, names[1].DefaultColor); + } + + [Fact] + public void TheHighlightSurvivesARebuild() + { + // A rebuild discards the row objects, so a remembered selection has to + // be re-PAINTED onto the new ones rather than merely kept. + (UiElement page, _) = BuildPage(); + using var state = new RuntimeContractState(); + Track(state, 0x10u, stage: 2u); + Track(state, 0x20u, stage: 2u); + + JournalContractsPageController controller = Bind(page, state, Catalog( + Entry(0x10u, "First"), Entry(0x20u, "Second"))); + controller.Select(0x20u); + + Track(state, 0x30u, stage: 2u); // forces a rebuild + controller.Tick(); + + Assert.Equal(0x20u, controller.SelectedContractId); + UiText selected = UiElement.FindDescendant(page, ListId)!.Children + .SelectMany(Flatten) + .OfType() + .Where(t => t.DatElementId == RowNameId) + .ElementAt(1); + UiText unselected = UiElement.FindDescendant(page, ListId)!.Children + .SelectMany(Flatten) + .OfType() + .Where(t => t.DatElementId == RowNameId) + .ElementAt(0); + Assert.NotEqual(unselected.DefaultColor, selected.DefaultColor); + } + [Fact] public void AProgressCounterRendersThroughTheAuthoredFormat() { From fe1e68e5feda5fc628ef220b520edc28458be3d9 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 15:22:22 +0200 Subject: [PATCH 34/43] =?UTF-8?q?feat(quest):=20QT6=20=E2=80=94=20plugins?= =?UTF-8?q?=20can=20read=20the=20contract=20tracker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last piece of QT6's own scope: r10-quest-dialogs.md §11.6's contract half. IGameState.Contracts exposes what the client structurally knows about quests, which — per that same research — is the tracker and nothing else. The rest of §11.6 (chat stream, tells, give, use, confirmations) is other features and stays out of this campaign. A pull-through source rather than a pushed mirror. Contracts change rarely and are already owned canonically, so a second copy would only be a thing to keep in step; reading through means a plugin cannot observe a stale list. Both hosts implement it. The headless one carries contract id, stage and progress but no names — a bot has no dat access — because losing the TEXT is expected while losing the QUEST would leave a bot silently unable to see what it is on. Same rule covers a contract the installed dat has never heard of: it still projects, with empty text and a correct status, rather than vanishing. The interface member is defaulted so a host predating this campaign still satisfies IGameState. Two lazy catalog loads exist (the panel's and this one) rather than one shared instance. That is deliberate: threading a shared ContractCatalog through three composition records to avoid reading a 322-row immutable table at most twice per session would be plumbing for no correctness or performance gain, and the comment at the call site says so. Campaign QT is complete; the connected user gate is owed. Co-Authored-By: Claude Opus 5 --- .../2026-08-21-contract-tracker-campaign.md | 7 + .../Composition/SessionPlayerComposition.cs | 24 ++++ src/AcDream.Core/Plugins/WorldGameState.cs | 13 ++ .../Plugins/HeadlessPluginHost.cs | 19 +++ .../ContractSnapshot.cs | 41 ++++++ src/AcDream.Plugin.Abstractions/IGameState.cs | 11 ++ .../Gameplay/ContractPluginProjection.cs | 59 ++++++++ .../Gameplay/ContractPluginProjectionTests.cs | 131 ++++++++++++++++++ 8 files changed, 305 insertions(+) create mode 100644 src/AcDream.Plugin.Abstractions/ContractSnapshot.cs create mode 100644 src/AcDream.Runtime/Gameplay/ContractPluginProjection.cs create mode 100644 tests/AcDream.Runtime.Tests/Gameplay/ContractPluginProjectionTests.cs diff --git a/docs/plans/2026-08-21-contract-tracker-campaign.md b/docs/plans/2026-08-21-contract-tracker-campaign.md index 16588821..a58fa9f4 100644 --- a/docs/plans/2026-08-21-contract-tracker-campaign.md +++ b/docs/plans/2026-08-21-contract-tracker-campaign.md @@ -174,6 +174,13 @@ the toolbar was ported — it simply had no panel registered behind it, so clicking it did nothing. Registering slot 25 completed a wiring that was already three-quarters present. +**The plugin surface** (`r10-quest-dialogs.md` §11.6's contract half) ships as +`IGameState.Contracts`, projected through `ContractPluginProjection` — a +pull-through view of the canonical tracker, never a mirror. Both hosts +implement it; the headless one carries the numeric fields without the authored +text, since a bot has no dat access. The rest of §11.6 (chat stream, tells, +give, use, confirmations) is other features and stays out of Campaign QT. + ### Owed - The connected user gate: accept a quest against live ACE, open the Journal diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 2af1a65b..879b9920 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -278,6 +278,30 @@ internal sealed class SessionPlayerCompositionPhase d.DatLock, world.TerrainBuild.HeightTable, d.Options.DumpSceneryZ); + // Campaign QT slice QT6: the plugin-facing contract view. A + // pull-through source rather than a mirror, so a plugin always reads + // the canonical tracker instead of a copy that could fall behind it. + // + // The catalog is loaded lazily and independently of the Journal + // panel's own. Two reads of a 322-row immutable table across a whole + // session is not worth threading a shared instance through three + // composition records for; correctness is identical either way. + AcDream.Core.Quests.ContractCatalog? pluginContractCatalog = null; + d.WorldGameState.ContractsSource = () => + { + if (pluginContractCatalog is null) + { + lock (d.DatLock) + pluginContractCatalog = + AcDream.Content.ContractTableReader.Load(content.Dats); + } + + return AcDream.Runtime.Gameplay.ContractPluginProjection.Project( + d.Runtime.ContractsOwner.View, + pluginContractCatalog, + DateTime.UtcNow); + }; + var streamerLease = scope.Acquire( "landblock streamer", () => LandblockStreamer.CreateForRequests( diff --git a/src/AcDream.Core/Plugins/WorldGameState.cs b/src/AcDream.Core/Plugins/WorldGameState.cs index 03fcb13d..e240555a 100644 --- a/src/AcDream.Core/Plugins/WorldGameState.cs +++ b/src/AcDream.Core/Plugins/WorldGameState.cs @@ -10,6 +10,19 @@ public sealed class WorldGameState : IGameState public IReadOnlyList Entities => _entities; + /// + /// Where reads from. Set once by the host. + /// + /// + /// A pull-through source rather than a pushed list: contracts change rarely + /// and are already owned canonically elsewhere, so mirroring them here + /// would add a second copy to keep in step for no gain. + /// + public Func>? ContractsSource { get; set; } + + public IReadOnlyList Contracts => + ContractsSource?.Invoke() ?? []; + /// /// Publish the current projection for an entity. Re-hydration replaces the /// prior snapshot instead of turning the current-state API into history. diff --git a/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs b/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs index 9fa7857f..45b99503 100644 --- a/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs +++ b/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs @@ -99,6 +99,25 @@ internal sealed class HeadlessPluginHost } } + /// + /// Campaign QT slice QT6. Same borrow-don't-own shape as + /// : projected from the canonical tracker on read. + /// Names and status text are empty here — a headless host has no dat + /// access — while every numeric field a bot actually branches on + /// (contract id, stage, progress) is present. + /// + public IReadOnlyList Contracts + { + get + { + ObjectDisposedException.ThrowIf(_disposed, this); + return AcDream.Runtime.Gameplay.ContractPluginProjection.Project( + _runtime.ContractsOwner.View, + catalog: null, + now: DateTime.UtcNow); + } + } + public event Action EntitySpawned { add diff --git a/src/AcDream.Plugin.Abstractions/ContractSnapshot.cs b/src/AcDream.Plugin.Abstractions/ContractSnapshot.cs new file mode 100644 index 00000000..7633d451 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/ContractSnapshot.cs @@ -0,0 +1,41 @@ +namespace AcDream.Plugin.Abstractions; + +/// +/// One tracked contract, as a plugin sees it. +/// +/// +/// +/// This is the ONLY structured view of quest state a client ever has. The +/// retail client stores no quest flags and is never told one changed; it learns +/// about quests through NPC dialogue, generic error text, and this tracker. A +/// plugin asking "what quests am I on?" is asking this and nothing else. +/// +/// +/// , and +/// come from the installed dat and may be empty on a +/// host with no dat access (a headless bot), or for a contract the installed +/// dat build has never heard of. The numeric fields are always present. +/// +/// +/// Key into the dat's ContractTable. +/// +/// Raw wire stage: 1 available, 2 in progress, 3 done-or-pending-repeat, and +/// 4 + n for a progress counter with n steps done — see +/// . +/// +/// Completed steps, or 0 when the stage carries no counter. +/// Whether the server nominated this as the shown contract. +/// Authored contract name. +/// Authored long-form description. +/// +/// The progress text retail's own panel shows — "Available", "In Progress", +/// "5/20 Tuskers", "Done (1h 30s to Repeat)". +/// +public readonly record struct ContractSnapshot( + uint ContractId, + uint Stage, + uint Progress, + bool IsDisplayed, + string Name = "", + string Description = "", + string Status = ""); diff --git a/src/AcDream.Plugin.Abstractions/IGameState.cs b/src/AcDream.Plugin.Abstractions/IGameState.cs index e3d640cd..30b698a3 100644 --- a/src/AcDream.Plugin.Abstractions/IGameState.cs +++ b/src/AcDream.Plugin.Abstractions/IGameState.cs @@ -4,4 +4,15 @@ namespace AcDream.Plugin.Abstractions; public interface IGameState { IReadOnlyList Entities { get; } + + /// + /// The player's tracked contracts — the client's only structured view of + /// quest state (r10-quest-dialogs.md §1.3). Empty when the server + /// has sent none. + /// + /// + /// Defaulted so a host predating Campaign QT still satisfies the interface; + /// both in-tree hosts implement it. + /// + IReadOnlyList Contracts => []; } diff --git a/src/AcDream.Runtime/Gameplay/ContractPluginProjection.cs b/src/AcDream.Runtime/Gameplay/ContractPluginProjection.cs new file mode 100644 index 00000000..a4f69643 --- /dev/null +++ b/src/AcDream.Runtime/Gameplay/ContractPluginProjection.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using AcDream.Core.Net.Messages; +using AcDream.Core.Quests; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Runtime.Gameplay; + +/// +/// Projects the canonical contract tracker into the plugin-facing +/// shape. +/// +/// +/// Lives here rather than in either host because both of them need it and +/// neither owns the tracker. The authored catalog is optional: a headless bot +/// has no dat access, and a contract the installed dat has never heard of still +/// has to appear — a plugin must not silently miss a live quest because the +/// text for it is unavailable. +/// +public static class ContractPluginProjection +{ + public static IReadOnlyList Project( + IRuntimeContractView contracts, + ContractCatalog? catalog, + DateTime now) + { + ArgumentNullException.ThrowIfNull(contracts); + + IReadOnlyList tracked = contracts.GetContracts(); + if (tracked.Count == 0) + return []; + + uint displayed = contracts.Snapshot.DisplayContractId; + var result = new ContractSnapshot[tracked.Count]; + for (int i = 0; i < tracked.Count; i++) + { + ContractTracker tracker = tracked[i]; + ContractEntry? entry = catalog?.Lookup(tracker.ContractId); + + result[i] = new ContractSnapshot( + tracker.ContractId, + (uint)tracker.Stage, + tracker.Progress, + tracker.ContractId == displayed, + entry?.ContractName ?? string.Empty, + entry?.Description ?? string.Empty, + entry is null + ? string.Empty + : ContractProgressText.Build( + (uint)tracker.Stage, + tracker.TimeWhenRepeats, + tracker.ReceivedAt, + entry, + now)); + } + + return result; + } +} diff --git a/tests/AcDream.Runtime.Tests/Gameplay/ContractPluginProjectionTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/ContractPluginProjectionTests.cs new file mode 100644 index 00000000..c30ba70e --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Gameplay/ContractPluginProjectionTests.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AcDream.Core.Net.Messages; +using AcDream.Core.Quests; +using AcDream.Plugin.Abstractions; +using AcDream.Runtime.Gameplay; + +namespace AcDream.Runtime.Tests.Gameplay; + +/// +/// Campaign QT slice QT6: what a plugin sees of the contract tracker. +/// +public sealed class ContractPluginProjectionTests +{ + private static readonly DateTime Now = new(2026, 8, 21, 12, 0, 0, DateTimeKind.Utc); + + private static void Track( + RuntimeContractState state, + uint contractId, + uint stage, + bool setAsDisplay = false) + => state.ApplyUpdate(new ContractTrackerUpdate( + new ContractTracker(1u, contractId, (ContractStage)stage, 0d, 0d, Now), + Delete: false, + SetAsDisplay: setAsDisplay)); + + private static ContractCatalog Catalog(uint id, string name, string progressFormat = "") + => new(new Dictionary + { + [id] = ContractEntry.Unknown with + { + ContractId = id, + ContractName = name, + Description = "Do the thing.", + DescriptionProgress = progressFormat, + }, + }); + + [Fact] + public void AnEmptyTrackerProjectsToNothing() + { + using var state = new RuntimeContractState(); + + Assert.Empty(ContractPluginProjection.Project(state.View, ContractCatalog.Empty, Now)); + } + + [Fact] + public void TheProjectionCarriesTheAuthoredTextAndTheRetailStatus() + { + using var state = new RuntimeContractState(); + Track(state, 0x10u, stage: 9u); // ProgressCounter + 5 + + ContractSnapshot snapshot = Assert.Single(ContractPluginProjection.Project( + state.View, Catalog(0x10u, "Tusker Hunt", "%d/20 Tuskers"), Now)); + + Assert.Equal(0x10u, snapshot.ContractId); + Assert.Equal(9u, snapshot.Stage); + Assert.Equal(5u, snapshot.Progress); + Assert.Equal("Tusker Hunt", snapshot.Name); + Assert.Equal("Do the thing.", snapshot.Description); + Assert.Equal("5/20 Tuskers", snapshot.Status); + } + + [Fact] + public void TheDisplayContractIsFlagged() + { + using var state = new RuntimeContractState(); + Track(state, 0x10u, stage: 2u); + Track(state, 0x20u, stage: 2u, setAsDisplay: true); + + IReadOnlyList projected = ContractPluginProjection.Project( + state.View, ContractCatalog.Empty, Now); + + Assert.False(projected.Single(c => c.ContractId == 0x10u).IsDisplayed); + Assert.True(projected.Single(c => c.ContractId == 0x20u).IsDisplayed); + } + + [Fact] + public void WithNoCatalogTheNumbersStillProject() + { + // A headless bot has no dat access. Losing the text is expected; + // losing the QUEST would mean a bot silently unable to see what it is + // on, which is the failure this rules out. + using var state = new RuntimeContractState(); + Track(state, 0x10u, stage: 6u); + + ContractSnapshot snapshot = Assert.Single( + ContractPluginProjection.Project(state.View, catalog: null, Now)); + + Assert.Equal(0x10u, snapshot.ContractId); + Assert.Equal(6u, snapshot.Stage); + Assert.Equal(2u, snapshot.Progress); + Assert.Equal(string.Empty, snapshot.Name); + Assert.Equal(string.Empty, snapshot.Status); + } + + [Fact] + public void AContractTheCatalogDoesNotKnowStillProjects() + { + // Same rule as the panel: the server can track a contract this dat + // build has never heard of, and a plugin must not miss it. + using var state = new RuntimeContractState(); + Track(state, 0xDEADu, stage: 2u); + + ContractSnapshot snapshot = Assert.Single(ContractPluginProjection.Project( + state.View, Catalog(0x10u, "Something Else"), Now)); + + Assert.Equal(0xDEADu, snapshot.ContractId); + Assert.Equal(string.Empty, snapshot.Name); + // ContractEntry.Unknown still runs the progress arms, so an in-progress + // contract reads correctly even with no authored text. + Assert.Equal("In Progress", snapshot.Status); + } + + [Fact] + public void TheProjectionOrderMatchesTheTrackersOwn() + { + using var state = new RuntimeContractState(); + Track(state, 0x30u, stage: 2u); + Track(state, 0x10u, stage: 2u); + Track(state, 0x20u, stage: 2u); + + IReadOnlyList projected = ContractPluginProjection.Project( + state.View, ContractCatalog.Empty, Now); + + Assert.Equal( + new uint[] { 0x10u, 0x20u, 0x30u }, + projected.Select(c => c.ContractId).ToArray()); + } +} From 45c964dbf0e5e9b7aed346e8c66ef53d043cc919 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 15:26:20 +0200 Subject: [PATCH 35/43] =?UTF-8?q?docs(journal):=20plan=20Campaign=20QJ=20?= =?UTF-8?q?=E2=80=94=20the=20two=20tabs=20QT=20deliberately=20left=20inert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Journal tab is not a quest feature at all: it is a per-character notebook, entirely client-side, with no wire and no dat content. Each page holds a label, a title, notes, a recorded location and a countdown timer, and the Page List tab is a searchable index over them. It shares the panel with Contracts and nothing else — retail's own naming, not a mix-up here. The file format is recovered whole, including the detail that a journal file which does not open with is REFUSED with its own error string, and the authored max lengths (16 label / 32 title / 2048 notes) that the edit boxes enforce. One authored fact worth recording before anyone reads the layout as a bug: the running-timer readout shares x=84 with the three day/hour/minute fields. That overlap is the data form of ShowEditableTimer versus ShowRunningTimer — the strip is either three editable numbers or one running readout, never both. Co-Authored-By: Claude Opus 5 --- docs/plans/2026-08-21-journal-campaign.md | 99 +++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 docs/plans/2026-08-21-journal-campaign.md diff --git a/docs/plans/2026-08-21-journal-campaign.md b/docs/plans/2026-08-21-journal-campaign.md new file mode 100644 index 00000000..33e8e419 --- /dev/null +++ b/docs/plans/2026-08-21-journal-campaign.md @@ -0,0 +1,99 @@ +# Campaign QJ — the Journal and Page List tabs + +**Status:** ACTIVE 2026-08-21. Completes the panel Campaign QT mounted: QT +shipped the Contracts tab and left the other two inert by design. + +**Scope:** retail's `gmJournalUI` (element type `0x10000048`, page +`0x10000563`) and `gmPageListUI` (type `0x10000049`, page `0x10000564`). + +## What this actually is + +A **per-character notebook**, entirely client-side. No wire, no server +involvement, no dat content — the player writes the pages. Each page carries a +label, a title, free-form notes, a recorded LOCATION, and a countdown TIMER. +The Page List tab is a searchable index over those pages. + +Nothing about it depends on quests; it shares the panel with Contracts and +nothing else. That it is called "Journal" while the panel is also called +"Journal" is retail's own naming, not a mistake here. + +## Measured ground truth + +### The file format + +`gmJournalUI::SavePages @0x00497270` / `LoadPages @0x00496AC0`. A plain tagged +text file, `fopen` mode `w+`. Both call sites pass the literal prefix +`"Journal"`; the path template is `%s%s-%s-%s.txt`, i.e. +`{dir}Journal-{server}-{character}.txt`. + +``` + begins a page (a file that does not open with one is refused) + %d page number + %s label (authored max length 16) + %s title (32) + %s notes (2048) + %d timer days + %d timer hours + %d timer minutes + %f recorded location + %f + public const uint ContractsPageId = 0x100005D4u; + /// The notes page — gmJournalUI, type 0x10000048. + public const uint NotesPageId = 0x10000563u; + + /// The index page — gmPageListUI, type 0x10000049. + public const uint PageListPageId = 0x10000564u; + /// The panel's own corner button. private const uint CloseButtonId = 0x10000562u; private readonly UiTabPanel _tabPanel; private readonly JournalContractsPageController? _contracts; + private readonly JournalNotesPageController? _notes; + private JournalPageListController? _pageList; + private readonly Action _onActivePageChanged; private bool _disposed; /// Root element of the imported panel — the tab host itself. @@ -58,17 +67,47 @@ public sealed class JournalPanelController : IRetainedPanelController public JournalContractsPageController? Contracts => _contracts; + public JournalNotesPageController? Notes => _notes; + + public JournalPageListController? PageList => _pageList; + + private readonly Action _saveJournal; + private JournalPanelController( UiTabPanel tabPanel, - JournalContractsPageController? contracts) + JournalContractsPageController? contracts, + JournalNotesPageController? notes, + JournalPageListController? pageList, + Action saveJournal) { _tabPanel = tabPanel; _contracts = contracts; + _notes = notes; + _pageList = pageList; + _saveJournal = saveJournal; + + // Retail saves the journal when the notes page is HIDDEN + // (gmJournalUI::OnVisibilityChanged @0x004978F0), not only at exit, so + // a crash costs at most the page you are looking at. Leaving the tab + // is that moment here. + _onActivePageChanged = (previous, _) => + { + if (previous == NotesPageId) + { + _notes?.OnHidden(); + _saveJournal(); + } + }; + _tabPanel.ActivePageChanged += _onActivePageChanged; } public sealed record Callbacks( Action Toggle, - JournalContractsPageController.Bindings Contracts); + JournalContractsPageController.Bindings Contracts, + JournalNotesPageController.Bindings Notes, + /// Writes the journal file — retail's save-on-hide. + Action SaveJournal, + Func, JournalPageListController.Bindings> PageList); /// /// Binds an imported / @@ -93,12 +132,40 @@ public sealed class JournalPanelController : IRetainedPanelController close.OnClick = callbacks.Toggle; JournalContractsPageController? contracts = null; - if (layout.FindElement(ContractsPageId) is { } page) - contracts = new JournalContractsPageController(page, callbacks.Contracts); + if (layout.FindElement(ContractsPageId) is { } contractsPage) + contracts = new JournalContractsPageController(contractsPage, callbacks.Contracts); else Console.WriteLine("[D.2b] JournalPanelController: contracts page not found."); - return new JournalPanelController(tabPanel, contracts); + JournalNotesPageController? notes = null; + if (layout.FindElement(NotesPageId) is { } notesPage) + notes = new JournalNotesPageController(notesPage, callbacks.Notes); + else + Console.WriteLine("[D.2b] JournalPanelController: notes page not found."); + + JournalPageListController? pageList = null; + var built = new JournalPanelController( + tabPanel, contracts, notes, pageList: null, callbacks.SaveJournal); + if (layout.FindElement(PageListPageId) is { } listPage) + { + // The index opens a page on the NOTES tab, so it needs the panel + // that owns the tab switch — hence the deferred binding. + pageList = new JournalPageListController( + listPage, + callbacks.PageList(pageNumber => + { + notes?.CommitText(); + callbacks.Notes.Commands.GotoPage(pageNumber); + built.ShowNotes(); + })); + } + else + { + Console.WriteLine("[D.2b] JournalPanelController: page list not found."); + } + + built.AttachPageList(pageList); + return built; } /// @@ -110,11 +177,32 @@ public sealed class JournalPanelController : IRetainedPanelController /// Switches to the Contracts tab. public void ShowContracts() => _tabPanel.SwitchTo(ContractsPageId); + /// Switches to the notes tab — what opening a page from the index does. + public void ShowNotes() => _tabPanel.SwitchTo(NotesPageId); + + /// + /// Completes construction. The index needs a callback that switches tabs, + /// which needs the panel — so it is attached rather than constructed. + /// + private void AttachPageList(JournalPageListController? pageList) + => _pageList = pageList; + public void Tick() { if (_disposed) return; _contracts?.Tick(); + _notes?.Tick(); + _pageList?.Tick(); } - public void Dispose() => _disposed = true; + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _tabPanel.ActivePageChanged -= _onActivePageChanged; + + // The panel closing is the other half of retail's save-on-hide. + _notes?.OnHidden(); + _saveJournal(); + } } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 3d7b22c8..454be105 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -317,7 +317,17 @@ public sealed record MapHouseRuntimeBindings( /// public sealed record QuestRuntimeBindings( AcDream.Runtime.Gameplay.IRuntimeContractView Contracts, - Func Catalog); + Func Catalog, + // Campaign QJ (2026-08-21): the notebook the other two tabs share. The + // command owner is passed alongside its own read view because the journal + // is WRITTEN by the panel — unlike contracts, which are server state. + AcDream.Runtime.Gameplay.IRuntimeJournalView Journal, + AcDream.Runtime.Gameplay.RuntimeJournalState JournalCommands, + Func PlayerCell, + /// Where the per-character journal file lives. + string JournalDirectory, + /// How a load or save failure reaches the player. + Action Report); public sealed record InventoryRuntimeBindings( ClientObjectTable Objects, @@ -705,6 +715,29 @@ public sealed class RetailUiRuntime : IDisposable /// Campaign QT slice QT5 — the three-tab Journal panel. public Layout.JournalPanelController? JournalPanelController { get; private set; } + + private JournalPersistence? _journalFile; + + /// + /// Campaign QJ slice QJ5. One owner, here rather than in the session + /// factory, because the panel that writes the journal lives here — two + /// instances would each track their own file path and overwrite each + /// other. + /// + private JournalPersistence JournalFile => + _journalFile ??= new JournalPersistence( + _bindings.Quests.JournalCommands, + _bindings.Quests.JournalDirectory, + _bindings.Quests.Report); + + /// Loads a character's journal — called on entering the world. + public void LoadJournal(string characterName) => JournalFile.Load(characterName); + + /// + /// Writes the journal if anything changed. Retail's own moment is the notes + /// page being hidden, not only session exit. + /// + public void SaveJournal() => JournalFile.Save(DateTime.UtcNow); public MapHousePanelController? MapHousePanelController { get; private set; } internal CharacterManagementUiController? CharacterManagementController => _characterManagementMount?.Controller; @@ -3520,7 +3553,23 @@ public sealed class RetailUiRuntime : IDisposable { lock (_bindings.Assets.DatLock) return rowTemplates.Resolve(templateLayoutId, templateElementId); - })); + }), + Notes: new Layout.JournalNotesPageController.Bindings( + Journal: _bindings.Quests.Journal, + Commands: _bindings.Quests.JournalCommands, + PlayerCell: _bindings.Quests.PlayerCell, + Now: () => DateTime.UtcNow), + SaveJournal: SaveJournal, + PageList: openPage => new Layout.JournalPageListController.Bindings( + Journal: _bindings.Quests.Journal, + Commands: _bindings.Quests.JournalCommands, + OpenPage: openPage, + TemplateResolver: (templateLayoutId, templateElementId) => + { + lock (_bindings.Assets.DatLock) + return rowTemplates.Resolve(templateLayoutId, templateElementId); + }, + Now: () => DateTime.UtcNow)); Layout.JournalPanelController? controller; lock (_bindings.Assets.DatLock) diff --git a/src/AcDream.Core/Quests/ContractProgressText.cs b/src/AcDream.Core/Quests/ContractProgressText.cs index 29cc5b2f..aa605fb2 100644 --- a/src/AcDream.Core/Quests/ContractProgressText.cs +++ b/src/AcDream.Core/Quests/ContractProgressText.cs @@ -1,6 +1,6 @@ using System; using System.Globalization; -using System.Text; +using AcDream.Core.Ui; namespace AcDream.Core.Quests; @@ -10,62 +10,6 @@ namespace AcDream.Core.Quests; /// public static class ContractProgressText { - private const int SecondsPerMonth = 0x278D00; // 2,592,000 — a 30-day month - private const int SecondsPerDay = 0x15180; // 86,400 - private const int SecondsPerHour = 0xE10; // 3,600 - private const int SecondsPerMinute = 0x3C; // 60 - - /// - /// Port of ClientUISystem::DeltaTimeToString @0x00565E10. - /// - /// - /// - /// Largest-unit-first, each unit omitted when zero, seconds always shown: - /// "2d 3h 4m 5s", "45s". A "month" is a flat 30 days. - /// - /// - /// Every part is emitted with a TRAILING space and the final one is then - /// truncated. That truncation is not visible in the decompiler output — - /// the instruction reads as noise — so it was settled by decoding the - /// bytes: at 0x00565F0E, mov byte ptr [esp+eax+0x1b], cl - /// with cl == 0 and eax == strlen writes the terminator over - /// buffer[len - 1]. Without it, the caller composes - /// "Done (1h 30s to Repeat)" with a double space. - /// - /// - public static string DeltaTimeToString(double seconds) - { - // Retail's _ftol2 — truncation toward zero, matching a C cast. - long total = (long)seconds; - if (total < 0) total = 0; - - long months = total / SecondsPerMonth; - long rest = total % SecondsPerMonth; - long days = rest / SecondsPerDay; - rest %= SecondsPerDay; - long hours = rest / SecondsPerHour; - rest %= SecondsPerHour; - long minutes = rest / SecondsPerMinute; - long secs = rest % SecondsPerMinute; - - var text = new StringBuilder(); - if (months != 0) Append(text, months, "mo"); - if (days != 0) Append(text, days, "d"); - if (hours != 0) Append(text, hours, "h"); - if (minutes != 0) Append(text, minutes, "m"); - Append(text, secs, "s"); - - // The trailing space the last part just wrote. - return text.ToString(0, text.Length - 1); - - static void Append(StringBuilder text, long value, string unit) - { - text.Append(value.ToString(CultureInfo.InvariantCulture)); - text.Append(unit); - text.Append(' '); - } - } - /// /// The progress text for one tracked contract. /// @@ -119,7 +63,7 @@ public static class ContractProgressText double remaining = timeWhenRepeats - elapsed; if (remaining <= 0d) return "Available"; - return $"Done ({DeltaTimeToString(remaining)} to Repeat)"; + return $"Done ({RetailDurationText.Format(remaining)} to Repeat)"; } if (stage >= 4u) diff --git a/src/AcDream.Core/Ui/RetailDurationText.cs b/src/AcDream.Core/Ui/RetailDurationText.cs new file mode 100644 index 00000000..91458a7a --- /dev/null +++ b/src/AcDream.Core/Ui/RetailDurationText.cs @@ -0,0 +1,74 @@ +using System; +using System.Globalization; +using System.Text; + +namespace AcDream.Core.Ui; + +/// +/// Retail's client-wide duration wording — +/// ClientUISystem::DeltaTimeToString @0x00565E10. +/// +/// +/// Client-wide rather than per-feature: the contract tracker's repeat countdown +/// and the journal page's timer both call it, so both read identically. It +/// lived in the contract code first only because that was its first caller. +/// +public static class RetailDurationText +{ + private const int SecondsPerMonth = 0x278D00; // 2,592,000 — a 30-day month + private const int SecondsPerDay = 0x15180; // 86,400 + private const int SecondsPerHour = 0xE10; // 3,600 + private const int SecondsPerMinute = 0x3C; // 60 + + /// + /// Port of ClientUISystem::DeltaTimeToString @0x00565E10. + /// + /// + /// + /// Largest-unit-first, each unit omitted when zero, seconds always shown: + /// "2d 3h 4m 5s", "45s". A "month" is a flat 30 days. + /// + /// + /// Every part is emitted with a TRAILING space and the final one is then + /// truncated. That truncation is not visible in the decompiler output — + /// the instruction reads as noise — so it was settled by decoding the + /// bytes: at 0x00565F0E, mov byte ptr [esp+eax+0x1b], cl + /// with cl == 0 and eax == strlen writes the terminator over + /// buffer[len - 1]. Without it, the caller composes + /// "Done (1h 30s to Repeat)" with a double space. + /// + /// + public static string Format(double seconds) + { + // Retail's _ftol2 — truncation toward zero, matching a C cast. + long total = (long)seconds; + if (total < 0) total = 0; + + long months = total / SecondsPerMonth; + long rest = total % SecondsPerMonth; + long days = rest / SecondsPerDay; + rest %= SecondsPerDay; + long hours = rest / SecondsPerHour; + rest %= SecondsPerHour; + long minutes = rest / SecondsPerMinute; + long secs = rest % SecondsPerMinute; + + var text = new StringBuilder(); + if (months != 0) Append(text, months, "mo"); + if (days != 0) Append(text, days, "d"); + if (hours != 0) Append(text, hours, "h"); + if (minutes != 0) Append(text, minutes, "m"); + Append(text, secs, "s"); + + // The trailing space the last part just wrote. + return text.ToString(0, text.Length - 1); + + static void Append(StringBuilder text, long value, string unit) + { + text.Append(value.ToString(CultureInfo.InvariantCulture)); + text.Append(unit); + text.Append(' '); + } + } + +} diff --git a/tests/AcDream.App.Tests/UI/JournalPersistenceTests.cs b/tests/AcDream.App.Tests/UI/JournalPersistenceTests.cs new file mode 100644 index 00000000..f0a93abb --- /dev/null +++ b/tests/AcDream.App.Tests/UI/JournalPersistenceTests.cs @@ -0,0 +1,180 @@ +using System; +using System.IO; +using System.Linq; +using AcDream.App.UI; +using AcDream.Core.Journal; +using AcDream.Runtime.Gameplay; + +namespace AcDream.App.Tests.UI; + +/// +/// Campaign QJ slice QJ5: the per-character journal file. +/// +public sealed class JournalPersistenceTests : IDisposable +{ + private static readonly DateTime Now = new(2026, 8, 21, 12, 0, 0, DateTimeKind.Utc); + + private readonly string _directory = + Path.Combine(Path.GetTempPath(), "acdream-journal-" + Guid.NewGuid().ToString("N")); + + public void Dispose() + { + try { Directory.Delete(_directory, recursive: true); } + catch (IOException) { /* the test already said what it needed to */ } + } + + private (RuntimeJournalState Journal, JournalPersistence File, List Reports) New() + { + var journal = new RuntimeJournalState(); + var reports = new List(); + return (journal, new JournalPersistence(journal, _directory, reports.Add), reports); + } + + [Fact] + public void APageWrittenInOneSessionIsThereInTheNext() + { + // The whole point of the feature. + (RuntimeJournalState first, JournalPersistence firstFile, _) = New(); + firstFile.Load("Acdream"); + first.NewPage(); + first.UpdateCurrent("label", "A title", "Some notes."); + Assert.True(firstFile.Save(Now)); + first.Dispose(); + + (RuntimeJournalState second, JournalPersistence secondFile, _) = New(); + secondFile.Load("Acdream"); + + JournalPage page = Assert.Single(second.View.Pages); + Assert.Equal("A title", page.Title); + Assert.Equal("Some notes.", page.Notes); + second.Dispose(); + } + + [Fact] + public void EachCharacterGetsItsOwnFile() + { + // Sharing one file would show a character another's notes. + (RuntimeJournalState journal, JournalPersistence file, _) = New(); + + file.Load("Acdream"); + journal.NewPage(); + journal.UpdateCurrent("a", "Acdream's page", string.Empty); + file.Save(Now); + + file.Load("Someone Else"); + + Assert.Empty(journal.View.Pages); + journal.Dispose(); + } + + [Fact] + public void ACharacterWithNoFileLoadsAnEmptyJournalWithoutComplaining() + { + // First-time use is the ordinary case, not an error. + (RuntimeJournalState journal, JournalPersistence file, List reports) = New(); + + file.Load("Newcomer"); + + Assert.Empty(journal.View.Pages); + Assert.Empty(reports); + journal.Dispose(); + } + + [Fact] + public void AMalformedFileReportsAndLeavesTheJournalEmpty() + { + // Half-reading a notebook loses pages, and the next save would then + // write that loss back over the original. + (RuntimeJournalState journal, JournalPersistence file, List reports) = New(); + Directory.CreateDirectory(_directory); + File.WriteAllText( + Path.Combine(_directory, JournalFile.FileNameFor("acdream", "Acdream")), + " no page marker first\n"); + + file.Load("Acdream"); + + Assert.Equal(JournalFile.MalformedFileMessage, Assert.Single(reports)); + Assert.Empty(journal.View.Pages); + journal.Dispose(); + } + + [Fact] + public void SavingIsSkippedWhenNothingChanged() + { + // Retail's save fires on every hide; rewriting an unchanged file on + // every tab switch is pure disk churn. + (RuntimeJournalState journal, JournalPersistence file, _) = New(); + file.Load("Acdream"); + journal.NewPage(); + Assert.True(file.Save(Now)); + + Assert.False(file.Save(Now)); + journal.Dispose(); + } + + [Fact] + public void SavingBeforeAnyCharacterIsLoadedIsANoOp() + { + // The panel can be disposed before a character ever entered the world. + (RuntimeJournalState journal, JournalPersistence file, _) = New(); + + Assert.False(file.Save(Now)); + Assert.False(Directory.Exists(_directory)); + journal.Dispose(); + } + + [Fact] + public void ARunningTimerPersistsItsREMAININGTime() + { + // Saving the value it started at would resurrect the full duration on + // every reload. + (RuntimeJournalState journal, JournalPersistence file, _) = New(); + file.Load("Acdream"); + journal.NewPage(); + journal.SetTimer(0, 1, 0); + journal.StartTimer(Now); + + file.Save(Now.AddMinutes(30)); + journal.Dispose(); + + (RuntimeJournalState reloaded, JournalPersistence reloadedFile, _) = New(); + reloadedFile.Load("Acdream"); + + Assert.Equal(1800d, Assert.Single(reloaded.View.Pages).RunningTimerSeconds); + reloaded.Dispose(); + } + + [Fact] + public void CloseSavesAndForgetsTheCharacter() + { + (RuntimeJournalState journal, JournalPersistence file, _) = New(); + file.Load("Acdream"); + journal.NewPage(); + + file.Close(Now); + + Assert.Null(file.CurrentPath); + Assert.False(file.Save(Now)); + journal.Dispose(); + } + + [Fact] + public void AnUnwritableDirectoryReportsRatherThanThrowing() + { + // Losing a note is bad; taking the client down while the player is + // writing one is worse. + var journal = new RuntimeJournalState(); + var reports = new List(); + string blocked = Path.Combine(_directory, "blocked"); + Directory.CreateDirectory(_directory); + File.WriteAllText(blocked, "not a directory"); + + var file = new JournalPersistence(journal, blocked, reports.Add); + file.Load("Acdream"); + journal.NewPage(); + + Assert.False(file.Save(Now)); + Assert.NotEmpty(reports); + journal.Dispose(); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/JournalPageListControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/JournalPageListControllerTests.cs new file mode 100644 index 00000000..152137e8 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/JournalPageListControllerTests.cs @@ -0,0 +1,305 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Core.Journal; +using AcDream.Runtime.Gameplay; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Campaign QJ slice QJ4: the journal's searchable index +/// (gmPageListUI). +/// +public sealed class JournalPageListControllerTests +{ + private static readonly DateTime Now = new(2026, 8, 21, 12, 0, 0, DateTimeKind.Utc); + + private const uint ListId = 0x10000583u; + private const uint SearchFieldId = 0x10000587u; + private const uint DeleteButtonId = 0x10000585u; + private const uint RowNumberId = 0x1000058Au; + private const uint RowTitleId = 0x1000058Bu; + + // ── the search predicate, ported from PageContainsString ──────────── + + [Fact] + public void SearchMatchesLabelTitleOrNotes() + { + var page = new JournalPage(Label: "lab", Title: "tit", Notes: "not"); + + Assert.True(JournalPageListController.PageContainsString(page, "lab")); + Assert.True(JournalPageListController.PageContainsString(page, "tit")); + Assert.True(JournalPageListController.PageContainsString(page, "not")); + Assert.False(JournalPageListController.PageContainsString(page, "zzz")); + } + + [Fact] + public void SearchIsCaseSensitiveBecauseRetailUsesWcsstr() + { + // Making it insensitive would be friendlier and would be a divergence. + var page = new JournalPage(Title: "Aerlinthe"); + + Assert.True(JournalPageListController.PageContainsString(page, "Aer")); + Assert.False(JournalPageListController.PageContainsString(page, "aer")); + } + + [Fact] + public void AnEmptySearchMatchesEverything() + { + Assert.True(JournalPageListController.PageContainsString( + new JournalPage(), string.Empty)); + } + + // ── the list ──────────────────────────────────────────────────────── + + private static UiText Text(uint id) => new() + { + DatElementId = id, + Width = 90f, + Height = 20f, + DefaultColor = new System.Numerics.Vector4(0.8f, 0.8f, 0.8f, 1f), + }; + + private static UiElement? RowTemplate(uint layoutId, uint elementId) + { + if (layoutId != JournalPageListController.RowTemplateLayoutId + || elementId != JournalPageListController.RowTemplateElementId) + { + return null; + } + + // A UiDatElement, as the real Type-3 template resolves to. + var row = new UiDatElement( + new ElementInfo { Type = 3, Width = 270, Height = 20 }, + static _ => (0u, 0, 0)); + row.AddChild(Text(RowNumberId)); + row.AddChild(Text(RowTitleId)); + return row; + } + + private static (UiElement Page, UiField Search) BuildPage() + { + var list = new UiTemplateListBox( + new ElementInfo { Id = ListId, Type = 5, Width = 270, Height = 430 }, + static _ => (0u, 0, 0), + [new UiTemplateListEntry( + JournalPageListController.RowTemplateLayoutId, + JournalPageListController.RowTemplateElementId)], + scrollbarElementId: 0u) + { + DatElementId = ListId, + }; + + var search = new UiField { ElementId = SearchFieldId, Width = 118f, Height = 18f }; + search.DatElementId = SearchFieldId; + + var deleteButton = new UiButton( + new ElementInfo { Id = DeleteButtonId, Type = 1, Width = 60, Height = 18 }, + static _ => (0u, 0, 0)) + { + DatElementId = DeleteButtonId, + }; + + var page = new UiPanel { Width = 300f, Height = 500f }; + page.AddChild(list); + page.AddChild(search); + page.AddChild(deleteButton); + return (page, search); + } + + private static (JournalPageListController Controller, RuntimeJournalState State, + UiElement Page, UiField Search, List Opened) Bind(params JournalPage[] pages) + { + var state = new RuntimeJournalState(); + state.Load(pages); + (UiElement page, UiField search) = BuildPage(); + var opened = new List(); + + var controller = new JournalPageListController( + page, + new JournalPageListController.Bindings( + Journal: state.View, + Commands: state, + OpenPage: opened.Add, + TemplateResolver: RowTemplate, + Now: () => Now)); + + return (controller, state, page, search, opened); + } + + [Fact] + public void EveryPageIsListedWithItsNumber() + { + var (controller, state, page, _, _) = Bind( + new JournalPage(Title: "one"), new JournalPage(Title: "two")); + + Assert.Equal(new[] { 1, 2 }, controller.RowPages.ToArray()); + string[] numbers = Flatten(page) + .OfType() + .Where(t => t.DatElementId == RowNumberId) + .Select(t => t.LinesProvider!()[0].Text) + .ToArray(); + Assert.Equal(new[] { "1", "2" }, numbers); + state.Dispose(); + } + + [Fact] + public void SearchingFiltersTheListButKeepsRealPageNumbers() + { + // The row number must name the page in the JOURNAL, not its position + // in the filtered list — otherwise opening row 1 of a filtered list + // opens the wrong page. + var (controller, state, _, search, _) = Bind( + new JournalPage(Title: "alpha"), + new JournalPage(Title: "beta"), + new JournalPage(Title: "gamma")); + + search.SetText("beta"); + controller.Tick(); + + Assert.Equal(new[] { 2 }, controller.RowPages.ToArray()); + state.Dispose(); + } + + [Fact] + public void AFilteredOutSelectionIsDroppedSoDeleteCannotHitAHiddenPage() + { + var (controller, state, _, search, _) = Bind( + new JournalPage(Title: "alpha"), new JournalPage(Title: "beta")); + controller.Select(1); + + search.SetText("beta"); + controller.Tick(); + + Assert.Equal(0, controller.SelectedPage); + state.Dispose(); + } + + [Fact] + public void DeleteWithNothingSelectedDoesNothing() + { + var (_, state, page, _, _) = Bind(new JournalPage(Title: "only")); + + (UiElement.FindDescendant(page, DeleteButtonId) as UiButton)!.OnClick!(); + + Assert.Single(state.View.Pages); + state.Dispose(); + } + + [Fact] + public void DeleteRemovesTheSelectedPage() + { + var (controller, state, page, _, _) = Bind( + new JournalPage(Title: "one"), new JournalPage(Title: "two")); + controller.Select(1); + + (UiElement.FindDescendant(page, DeleteButtonId) as UiButton)!.OnClick!(); + + Assert.Equal("two", Assert.Single(state.View.Pages).Title); + Assert.Equal(0, controller.SelectedPage); + state.Dispose(); + } + + // ── CheckForDoubleClick ───────────────────────────────────────────── + + [Fact] + public void OneClickSelectsAndDoesNotOpen() + { + var (controller, state, _, _, opened) = Bind(new JournalPage(Title: "one")); + + controller.Click(1); + + Assert.Equal(1, controller.SelectedPage); + Assert.Empty(opened); + state.Dispose(); + } + + [Fact] + public void TwoClicksOnTheSameRowOpenIt() + { + var (controller, state, _, _, opened) = Bind(new JournalPage(Title: "one")); + + controller.Click(1); + controller.Click(1); + + Assert.Equal(new[] { 1 }, opened.ToArray()); + state.Dispose(); + } + + [Fact] + public void AThirdClickDoesNotReopenBecauseFiringResetsTheTracker() + { + // Retail clears m_LastClickIndex on a successful double-click + // (@0x0049318A). Without that, every click after the second re-opens. + var (controller, state, _, _, opened) = Bind(new JournalPage(Title: "one")); + + controller.Click(1); + controller.Click(1); + controller.Click(1); + + Assert.Single(opened); + state.Dispose(); + } + + [Fact] + public void ClicksOnDifferentRowsAreNotADoubleClick() + { + var (controller, state, _, _, opened) = Bind( + new JournalPage(Title: "one"), new JournalPage(Title: "two")); + + controller.Click(1); + controller.Click(2); + + Assert.Empty(opened); + Assert.Equal(2, controller.SelectedPage); + state.Dispose(); + } + + [Fact] + public void TheDoubleClickWindowIsAFullSecond() + { + // Retail's is m_LastClickTime + 1.0 (@0x00493158) — NOT the 500 ms the + // item-interaction path uses. Borrowing the wrong constant makes the + // list feel unresponsive. + var state = new RuntimeJournalState(); + state.Load([new JournalPage(Title: "one")]); + (UiElement page, _) = BuildPage(); + var opened = new List(); + DateTime now = Now; + + var controller = new JournalPageListController( + page, + new JournalPageListController.Bindings( + Journal: state.View, + Commands: state, + OpenPage: opened.Add, + TemplateResolver: RowTemplate, + Now: () => now)); + + controller.Click(1); + now = Now.AddMilliseconds(900); + controller.Click(1); + Assert.Single(opened); + + opened.Clear(); + now = Now.AddSeconds(10); + controller.Click(1); + now = now.AddMilliseconds(1100); + controller.Click(1); + Assert.Empty(opened); + + state.Dispose(); + } + + private static IEnumerable Flatten(UiElement e) + { + yield return e; + foreach (UiElement child in e.Children) + { + foreach (UiElement descendant in Flatten(child)) + yield return descendant; + } + } +} diff --git a/tests/AcDream.Core.Tests/Quests/ContractProgressTextTests.cs b/tests/AcDream.Core.Tests/Quests/ContractProgressTextTests.cs index 7de63e38..737399c1 100644 --- a/tests/AcDream.Core.Tests/Quests/ContractProgressTextTests.cs +++ b/tests/AcDream.Core.Tests/Quests/ContractProgressTextTests.cs @@ -1,5 +1,6 @@ using System; using AcDream.Core.Quests; +using AcDream.Core.Ui; namespace AcDream.Core.Tests.Quests; @@ -32,7 +33,7 @@ public sealed class ContractProgressTextTests [InlineData(2592000 + 86400 + 3600 + 61, "1mo 1d 1h 1m 1s")] public void DeltaTimeFormatsLargestUnitFirstAndAlwaysShowsSeconds( double seconds, string expected) - => Assert.Equal(expected, ContractProgressText.DeltaTimeToString(seconds)); + => Assert.Equal(expected, RetailDurationText.Format(seconds)); [Fact] public void DeltaTimeHasNoTrailingSpace() @@ -42,7 +43,7 @@ public sealed class ContractProgressTextTests // gives "Done (30s to Repeat)" with a double space — and the // instruction is invisible in the decompiler output, so this is the // assertion that pins the byte-level reading. - string text = ContractProgressText.DeltaTimeToString(30); + string text = RetailDurationText.Format(30); Assert.Equal("30s", text); Assert.DoesNotContain(" ", ContractProgressText.Build( @@ -52,7 +53,7 @@ public sealed class ContractProgressTextTests [Fact] public void DeltaTimeTruncatesTowardZeroLikeRetailsFtol() { - Assert.Equal("59s", ContractProgressText.DeltaTimeToString(59.99)); + Assert.Equal("59s", RetailDurationText.Format(59.99)); } // ── the stage arms ────────────────────────────────────────────────── From 73a04244e7924705a51adae87d2e2771cdd749b3 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 15:58:03 +0200 Subject: [PATCH 38/43] fix(ui): button property 0x0D was never "disabled", and it killed every Journal button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported symptom: Abandon, New, Record, Start, First and Last all unclickable. That Abandon was in the list is what identified it — Abandon is deliberately unwired, so if it behaved the same as the others the cause could not be wiring. UiButton read authored property 0x0D as "starts disabled" (Enabled = !0x0D). It was the one property read in that file with no citation, and it was wrong. Every button on the Journal panel authors 0x0D, so every one built disabled: visible, because drawing never consults Enabled, and unclickable, because UiElement.HitTest skips disabled elements. Exactly the reported shape. The evidence is a sweep of every installed layout (LayoutDump gained --ghosted for it): 85 elements author 0x0D and ALL 85 author it TRUE — not one False anywhere in the client — and no panel ever clears it, the only four SetAttribute_Bool(.., 0xd, ..) sites in the binary being chargen appearance, the keymap option and the barber. A flag that is only ever true, never cleared, and sits on New, Record, Start, Delete and Reset cannot mean "dead button"; under the old reading 85 elements were permanently dead in a shipping game. It is not a pure ghosted LOOK either, which is why this ignores it rather than moving it to appearance: the same 85 mix live buttons with inert column headers ("Contract", "Status", "Title", "Timer", "Label", "#"), and one appearance cannot be right for both. Registered as QJ-2 with the measurement, so the open question is recorded rather than quietly decided. The test that asserted the old behaviour carried no citation either — it encoded the same assumption. It now asserts the evidenced behaviour, with a companion test proving the state machine's own Ghosted transition still suppresses a click: that mechanism is separate and did not change. Co-Authored-By: Claude Opus 5 --- .../retail-divergence-register.md | 1 + src/AcDream.App/UI/UiButton.cs | 21 ++++++++++-- tests/AcDream.App.Tests/UI/UiButtonTests.cs | 33 +++++++++++++++++-- tools/LayoutDump/Program.cs | 33 +++++++++++++++++++ 4 files changed, 84 insertions(+), 4 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index e01f0253..2a0d6875 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -497,6 +497,7 @@ equivalence argument (promote to AD/AP) or a fix. | CT-5 | A bare `@log` filename lands in the client's own log directory (`ApplicationPathSet.LogsDirectory`), not the install directory retail names ("a log file named Aclog.txt in your Asheron's Call directory"). Rooted paths are honoured verbatim, as retail's `fopen` would | `src/AcDream.App/Net/LiveSessionRuntimeFactory.cs` (`_chatLogDirectory`); `src/AcDream.Core/Chat/ChatSessionLog.cs` | acdream's launcher replaces the install directory atomically on update, so a log written there is wiped by the next update or blocks it outright. Retail had no updater with that property. The client's own data directory is the equivalent that survives | A player following retail-era instructions looks for the file next to the executable and does not find it. The `/log` reply names the file, not the directory, so the path is discoverable only from this row and the code | `ClientCommunicationSystem::StartCopyOutputToFile @0x0057C8A0`; help text at `DoSetOutputHelp @0x0057A950` | | CT-6 | The `@log` file records the composed line WITHOUT retail's inline text-tag markup. Retail's `fprintf` runs before glyph parsing, so its logs contain literal `` markers around tagged names | `src/AcDream.App/UI/ChatTranscriptLogWriter.cs` | acdream never puts markup in the line: `ChatVM` carries tags as SPANS beside the text (CT-A2/A3), so there is no markup at that seam to preserve. Reconstructing it purely to write it to a file would be inventing a string the client does not otherwise produce | A log diffed against a retail-era log differs on tagged lines — acdream's are the clean ones. No in-client effect | `ClientSystem::AddTextToScroll` write at `@0x00563E5B`, upstream of `UIElement_Text::InqGlyphs @0x00468EA0` | | QJ-1 | The per-character journal file lives in the client's own data directory (`{data}/journal/Journal-{server}-{character}.txt`), not beside the executable where retail's sits | `src/AcDream.App/UI/JournalPersistence.cs`; path composed in `InteractionRetainedUiComposition` | Identical reasoning to CT-5: acdream's launcher replaces the install directory atomically on update, so a journal written there is destroyed by the next update. The file NAME follows retail's own `"%s%s-%s-%s.txt"` pattern exactly | A player migrating a retail journal must copy the file rather than find it picked up in place. No in-client effect | `gmJournalUI::LoadPages @0x00496AC0` / `SavePages @0x00497270` | +| QJ-2 | Authored button property `0x0D` is ignored. Retail's `UIElement_Button::UpdateState_ @0x00471CF0` reads it and selects the Ghosted visual state when set; acdream reads it for neither input nor appearance | `src/AcDream.App/UI/UiButton.cs` (constructor) | It CANNOT mean input-disabled: measured across every installed layout, 85 elements author `0x0D` and all 85 author it TRUE, never False, and no panel clears it (the only `SetAttribute_Bool(.., 0xd, ..)` sites are chargen appearance, the keymap option and the barber). Reading it as "disabled" made every Journal-panel button visible-but-unclickable. Nor can it be a pure ghosted LOOK: the same 85 include live buttons (New, Record, Start, Delete, Reset) alongside inert column headers, so one appearance cannot suit both | If `0x0D` turns out to drive appearance, the affected elements render un-ghosted where retail greys them — 85 elements, mostly column headers. No input or state effect | `UIElement_Button::OnSetAttribute @0x00471F40` case 0; `UpdateState_ @0x00471CFC` | --- diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs index 0f77bfd7..be2a82bf 100644 --- a/src/AcDream.App/UI/UiButton.cs +++ b/src/AcDream.App/UI/UiButton.cs @@ -506,7 +506,6 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful ? repeatInterval : 0f; _selected = info.TryGetEffectiveBool(0x0Eu, out bool selected) && selected; - bool disabled = info.TryGetEffectiveBool(0x0Du, out bool ghosted) && ghosted; // State defaulting matches UiDatElement exactly: // DefaultStateName wins; else "Normal" if that state has a sprite; else DirectState (""). @@ -516,7 +515,25 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful ActiveState = "Normal"; // else ActiveState stays "" (DirectState) - Enabled = !disabled; + // Property 0x0D was previously read here as "starts disabled" + // (Enabled = !0x0D). That was the one uncited property read in this + // file, and it was wrong. Measured across every installed layout + // (LayoutDump --ghosted): 85 elements author 0x0D and ALL 85 author it + // TRUE — not one False anywhere in the client. No panel ever clears it + // either: the only four SetAttribute_Bool(…, 0xd, …) sites in the + // binary are chargen appearance, the keymap option and the barber, and + // gmContractsUI/gmJournalUI never call SetAttribute at all. + // + // A flag that is only ever authored true, never cleared, and sits on + // elements that must be clickable — New, Record, Start, Delete, Reset, + // the search box — cannot mean "this button is dead". Reading it that + // way disabled every button on the Journal panel: visible, because + // drawing does not consult Enabled, but unclickable, because + // UiElement.HitTest skips disabled elements. + // + // Retail's UIElement_Button::UpdateState_ @0x00471CF0 uses 0x0D to + // pick the Ghosted VISUAL state, which the state machine below already + // models through ActiveState. Input is not its business. FaceWidth = mediaInfo?.Width ?? info.Width; FaceHeight = mediaInfo?.Height ?? info.Height; UpdateVisualState(); diff --git a/tests/AcDream.App.Tests/UI/UiButtonTests.cs b/tests/AcDream.App.Tests/UI/UiButtonTests.cs index aba80c27..966ad73b 100644 --- a/tests/AcDream.App.Tests/UI/UiButtonTests.cs +++ b/tests/AcDream.App.Tests/UI/UiButtonTests.cs @@ -142,8 +142,21 @@ public class UiButtonTests } [Fact] - public void DisabledProperty_SelectsGhostedAndSuppressesClick() + public void Property0x0DDoesNotDisableTheButton() { + // This test previously asserted the opposite, encoding the same + // uncited assumption the constructor made: that 0x0D means "starts + // disabled". Measured across every installed layout + // (LayoutDump --ghosted), 85 elements author 0x0D and ALL 85 author it + // TRUE — never once False — and no panel ever clears it: the only + // SetAttribute_Bool(…, 0xd, …) sites in the whole binary are chargen + // appearance, the keymap option and the barber. + // + // A flag that is only ever true, never cleared, and sits on New, + // Record, Start, Delete and Reset cannot mean "dead button". Reading + // it that way made every button on the Journal panel visible but + // unclickable, because drawing ignores Enabled while + // UiElement.HitTest skips disabled elements. var info = ButtonInfo("Normal", "Ghosted"); AddBoolProperty(info, 0x0Du, true); var b = CreateButton(info); @@ -151,8 +164,24 @@ public class UiButtonTests b.OnEvent(new UiEvent(0, b, UiEventType.Click)); + Assert.True(b.Enabled); + Assert.True(_clicked); + } + + [Fact] + public void AnExplicitGhostedStateStillSuppressesTheClick() + { + // The state machine's own Ghosted transition is a separate mechanism + // from the authored 0x0D property and is NOT what changed — a panel + // that deliberately ghosts a button still gets a dead button. + var info = ButtonInfo("Normal", "Ghosted"); + var b = CreateButton(info); + b.OnClick = () => _clicked = true; + + b.TrySetRetailState(UiButtonStateMachine.Ghosted); + b.OnEvent(new UiEvent(0, b, UiEventType.Click)); + Assert.False(b.Enabled); - Assert.Equal("Ghosted", b.ActiveState); Assert.False(_clicked); } diff --git a/tools/LayoutDump/Program.cs b/tools/LayoutDump/Program.cs index 8b72528c..4cbfe8ad 100644 --- a/tools/LayoutDump/Program.cs +++ b/tools/LayoutDump/Program.cs @@ -35,6 +35,39 @@ string datDir = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR") using var dats = new DatCollection(datDir, DatAccessType.Read); using var adapter = new DatCollectionAdapter(dats); +if (args.Contains("--ghosted")) +{ + // Which authored elements carry property 0x0D (retail's ghost flag)? + // Sizing the blast radius of how acdream interprets it. + int hits = 0; + foreach (uint layoutId in dats.GetAllIdsOfType() + .OrderBy(i => i)) + { + ElementInfo? candidate; + try { candidate = LayoutImporter.ImportInfos(adapter, layoutId); } + catch { continue; } + if (candidate is null) continue; + Walk(candidate, layoutId); + } + Console.WriteLine($"elements authoring 0x0D: {hits}"); + return 0; + + void Walk(ElementInfo e, uint layoutId) + { + foreach (var (_, state) in e.States) + { + if (state.Properties.Values.TryGetValue(0x0Du, out UiPropertyValue? v)) + { + hits++; + Console.WriteLine( + $"layout 0x{layoutId:X8} element 0x{e.Id:X8} type={e.Type} 0x0D={v.BoolValue}"); + break; + } + } + foreach (ElementInfo child in e.Children) Walk(child, layoutId); + } +} + if (args.Contains("--contracts")) { // Campaign QT slice QT2: what does the installed ContractTable actually From 0b27c5d0fefa362221bbfa1b7a4edd6e062e8f9f Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 15:59:19 +0200 Subject: [PATCH 39/43] test(ui): pin every Journal-panel button as enabled and reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Built from the REAL authored layout, because that is the only place property 0x0D exists — the hermetic UiButton tests construct their own ElementInfo and so could not have caught this class of bug at all. Asserts the two things the player actually experiences, separately: the button builds enabled, and a click at its centre reaches IT rather than falling through. Enabled alone would not have been enough to call the fix proven. Verified to have teeth rather than assumed: restoring the previous UiButton and re-running fails both tests, naming all ten buttons. Co-Authored-By: Claude Opus 5 --- .../JournalPanelButtonsAreClickableTests.cs | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 tests/AcDream.App.Tests/UI/Layout/JournalPanelButtonsAreClickableTests.cs diff --git a/tests/AcDream.App.Tests/UI/Layout/JournalPanelButtonsAreClickableTests.cs b/tests/AcDream.App.Tests/UI/Layout/JournalPanelButtonsAreClickableTests.cs new file mode 100644 index 00000000..b040147b --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/JournalPanelButtonsAreClickableTests.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Content; +using DatReaderWriter; +using DatReaderWriter.Options; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// The Journal panel's buttons must be clickable when built from the REAL +/// authored layout. +/// +/// +/// +/// Every button on this panel authors property 0x0D, which was read as +/// "starts disabled" — so all of them built visible and dead. The hermetic +/// UiButton tests could not catch it because they construct their own +/// ElementInfo; only the installed layout carries the property. +/// +/// +/// This asserts the two things the player actually experiences: the button is +/// enabled, and a click at its centre reaches IT rather than falling through to +/// whatever is behind. +/// +/// +[Trait("Lane", "InstalledDat")] +public sealed class JournalPanelButtonsAreClickableTests +{ + private static readonly (uint Id, string Name)[] Buttons = + [ + (0x100005DCu, "Abandon (contracts)"), + (0x10000567u, "New"), + (0x1000056Fu, "First"), + (0x10000571u, "Last"), + (0x10000565u, "Previous"), + (0x10000566u, "Next"), + (0x10000574u, "Record"), + (0x1000057Du, "Start"), + (0x10000585u, "Delete (page list)"), + (0x10000588u, "Reset (page list)"), + ]; + + private static ImportedLayout BuildPanel() + { + string? datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + if (string.IsNullOrWhiteSpace(datDir) || !Directory.Exists(datDir)) + { + datDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + } + + if (!Directory.Exists(datDir)) + { + Assert.Fail( + "Lane=InstalledDat requires an installed retail DAT directory; " + + "see docs/release-gate.md."); + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + + ElementInfo? root = LayoutImporter.ImportInfos( + adapter, + JournalPanelController.HostLayoutId, + JournalPanelController.SlotElementId); + Assert.NotNull(root); + + var strings = new DatStringResolver(adapter); + return LayoutImporter.Build(root!, _ => (0u, 0, 0), null, _ => null, strings.Resolve); + } + + [Fact] + public void EveryJournalPanelButtonBuildsEnabled() + { + ImportedLayout layout = BuildPanel(); + + var dead = new List(); + foreach ((uint id, string name) in Buttons) + { + if (layout.FindElement(id) is not UiButton button) + { + dead.Add($"{name} (0x{id:X8}) did not build as a UiButton"); + continue; + } + + if (!button.Enabled) + dead.Add($"{name} (0x{id:X8}) built DISABLED"); + } + + Assert.Empty(dead); + } + + [Fact] + public void EveryJournalPanelButtonIsReachableByAClickAtItsCentre() + { + // Enabled alone is not enough — the click has to actually land on it + // rather than pass through to something behind. + ImportedLayout layout = BuildPanel(); + + var unreachable = new List(); + foreach ((uint id, string name) in Buttons) + { + if (layout.FindElement(id) is not UiButton button) + continue; + + UiElement? hit = HitTestSelf(button); + if (!ReferenceEquals(hit, button)) + unreachable.Add($"{name} (0x{id:X8}) hit-tested to {hit?.GetType().Name ?? "nothing"}"); + } + + Assert.Empty(unreachable); + } + + /// Hit-tests a button at its own centre, in its own local space. + private static UiElement? HitTestSelf(UiButton button) + { + var hitTest = typeof(UiElement).GetMethod( + "HitTest", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; + return hitTest.Invoke( + button, + [button.Width / 2f, button.Height / 2f]) as UiElement; + } +} From e35d9386e4e0481986c642b585003df31664896a Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 16:19:18 +0200 Subject: [PATCH 40/43] fix(journal): the location readout is a FIELD, refresh at the click, and Abandon works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from the first connected round. The location readout is authored EDITABLE (0x16), so it builds as a UiField — not the UiText its "00.0S, 00.0W" placeholder suggests. The controller resolved it as text, got null, and threw every write away in silence: Record reached the model and reached the FILE, and never reached the screen. That is exactly what was reported, and it is a whole class of bug, so the sweep that found it is now a test over every element all three controllers bind. The handlers mutated the model and left redrawing to the next frame's Tick. Retail's ListenToElementMessage @0x004968D0 ends every one of them in Update() instead — at the moment of the click. The deferred version happened to work in the client and made the behaviour untestable and a frame late; the notes-page tests I had not written until now fail against it. Abandon is wired. "Retail's abandon path is a contract-registry command we have not ported" was wrong — it is game action 0x0316 with a single contract id, and ACE replies with the 0x0315 delete QT3 already handles. Nothing is removed locally, so a refusal leaves the quest visibly intact rather than vanishing it optimistically and having it reappear on the next full table. Co-Authored-By: Claude Opus 5 --- .../2026-08-21-contract-tracker-campaign.md | 8 +- docs/plans/2026-08-21-journal-campaign.md | 6 +- .../InteractionRetainedUiComposition.cs | 2 + .../Layout/JournalContractsPageController.cs | 32 +- .../UI/Layout/JournalNotesPageController.cs | 29 +- src/AcDream.App/UI/RetailUiRuntime.cs | 5 +- .../Messages/ClientCommandRequests.cs | 10 + src/AcDream.Core.Net/WorldSession.cs | 11 + .../JournalContractsPageControllerTests.cs | 49 +++ .../Layout/JournalNotesPageControllerTests.cs | 283 ++++++++++++++++++ .../JournalPanelBoundWidgetTypesTests.cs | 115 +++++++ .../Messages/ContractTrackerMessagesTests.cs | 22 ++ 12 files changed, 556 insertions(+), 16 deletions(-) create mode 100644 tests/AcDream.App.Tests/UI/Layout/JournalNotesPageControllerTests.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/JournalPanelBoundWidgetTypesTests.cs diff --git a/docs/plans/2026-08-21-contract-tracker-campaign.md b/docs/plans/2026-08-21-contract-tracker-campaign.md index a58fa9f4..986c553b 100644 --- a/docs/plans/2026-08-21-contract-tracker-campaign.md +++ b/docs/plans/2026-08-21-contract-tracker-campaign.md @@ -185,9 +185,11 @@ give, use, confirmations) is other features and stays out of Campaign QT. - The connected user gate: accept a quest against live ACE, open the Journal panel, confirm the list, the progress column and a repeat countdown. -- The Abandon button is deliberately unwired — retail's abandon path is a - contract-registry command this campaign did not port. It is authored and - visible; clicking it does nothing. +- ~~The Abandon button is deliberately unwired~~ **WIRED 2026-08-21.** The + claim that it had no wire message was wrong: it is game action `0x0316` + carrying one contract id, and ACE answers with the `0x0315` delete QT3 + already handles. Nothing is removed locally, so a refused abandon leaves the + quest visibly intact. - The Journal notes page and Page List tabs mount inert, by design. ## Definition of done diff --git a/docs/plans/2026-08-21-journal-campaign.md b/docs/plans/2026-08-21-journal-campaign.md index d7326164..413130c2 100644 --- a/docs/plans/2026-08-21-journal-campaign.md +++ b/docs/plans/2026-08-21-journal-campaign.md @@ -1,7 +1,9 @@ # Campaign QJ — the Journal and Page List tabs -**Status:** CODE-COMPLETE 2026-08-21. All five slices landed; the connected -user gate is owed. Completes the panel Campaign QT mounted: QT +**Status:** CODE-COMPLETE 2026-08-21. All five slices landed, plus the first +connected round's three fixes (button property `0x0D`, the location readout's +widget type, refresh-at-the-click). Abandon is now wired too — it turned out to +have a real wire action after all. The connected re-gate is owed. Completes the panel Campaign QT mounted: QT shipped the Contracts tab and left the other two inert by design. **Scope:** retail's `gmJournalUI` (element type `0x10000048`, page diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index b65fdd36..b44ded67 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -1029,6 +1029,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory Journal: d.Runtime.JournalOwner.View, JournalCommands: d.Runtime.JournalOwner, PlayerCell: () => d.PlayerController.Controller?.CellId ?? 0u, + AbandonContract: contractId => + late.Session.CurrentSession?.SendAbandonContract(contractId), JournalDirectory: System.IO.Path.Combine( AcDream.Platform.ApplicationPathSet.Resolve().DataDirectory, "journal"), diff --git a/src/AcDream.App/UI/Layout/JournalContractsPageController.cs b/src/AcDream.App/UI/Layout/JournalContractsPageController.cs index 8f2b0c62..8f49aff0 100644 --- a/src/AcDream.App/UI/Layout/JournalContractsPageController.cs +++ b/src/AcDream.App/UI/Layout/JournalContractsPageController.cs @@ -57,11 +57,17 @@ public sealed class JournalContractsPageController /// testable without waiting for it. /// /// Builds one row from the authored template. + /// + /// Sends the abandon action for one contract. The row disappears only when + /// the SERVER answers with its own delete, so a refused abandon leaves the + /// quest exactly where it was. + /// public sealed record Bindings( IRuntimeContractView Contracts, Func Catalog, Func Now, - Func TemplateResolver); + Func TemplateResolver, + Action? Abandon = null); private readonly Bindings _bindings; private readonly UiTemplateListBox? _list; @@ -104,11 +110,8 @@ public sealed class JournalContractsPageController _description = UiElement.FindDescendant(page, DescriptionId) as UiText; _timedValue = UiElement.FindDescendant(page, TimedValueId) as UiText; - // The Abandon button has no wire message in this campaign's scope — - // retail's own abandon path is a contract-registry command we have not - // ported. Left unwired rather than given a no-op handler that would - // look responsive and do nothing. - _ = AbandonButtonId; + if (UiElement.FindDescendant(page, AbandonButtonId) is UiButton abandon) + abandon.OnClick = AbandonSelected; Refresh(); } @@ -192,6 +195,23 @@ public sealed class JournalContractsPageController RefreshDetail(); } + /// + /// Abandons the selected contract — game action 0x0316. + /// + /// + /// Nothing is removed locally. The server replies with a 0x0315 + /// carrying DeleteContract and the tracker drops the row then, so a + /// refusal leaves the quest visibly intact rather than vanishing it + /// optimistically and having it reappear. + /// + public void AbandonSelected() + { + if (_selectedContractId == 0u) + return; + + _bindings.Abandon?.Invoke(_selectedContractId); + } + /// Points the detail pane at one contract. public void Select(uint contractId) { diff --git a/src/AcDream.App/UI/Layout/JournalNotesPageController.cs b/src/AcDream.App/UI/Layout/JournalNotesPageController.cs index b7d2bb81..45787285 100644 --- a/src/AcDream.App/UI/Layout/JournalNotesPageController.cs +++ b/src/AcDream.App/UI/Layout/JournalNotesPageController.cs @@ -69,7 +69,15 @@ public sealed class JournalNotesPageController private readonly UiField? _timerHours; private readonly UiField? _timerMinutes; private readonly UiText? _pageNumber; - private readonly UiText? _location; + + /// + /// The location readout is authored EDITABLE (0x16), so it builds as + /// a — not the its placeholder + /// text suggests. Resolving it as text yielded null and threw every write + /// away silently: Record reached the model and the file, and never the + /// screen. + /// + private readonly UiField? _location; private readonly UiText? _runningTimer; private readonly UiButton? _start; @@ -87,7 +95,7 @@ public sealed class JournalNotesPageController _timerHours = UiElement.FindDescendant(page, TimerHoursFieldId) as UiField; _timerMinutes = UiElement.FindDescendant(page, TimerMinutesFieldId) as UiField; _pageNumber = UiElement.FindDescendant(page, PageNumberId) as UiText; - _location = UiElement.FindDescendant(page, LocationTextId) as UiText; + _location = UiElement.FindDescendant(page, LocationTextId) as UiField; _runningTimer = UiElement.FindDescendant(page, RunningTimerTextId) as UiText; _start = UiElement.FindDescendant(page, StartButtonId) as UiButton; @@ -108,7 +116,16 @@ public sealed class JournalNotesPageController field.OnFocusLost = _ => CommitText(); } - Bind(page, NewButtonId, () => { CommitText(); _bindings.Commands.NewPage(); }); + // Retail's ListenToElementMessage @0x004968D0 ends every one of these + // in Update() — the panel redraws at the moment of the click, not on + // the next frame. Deferring to Tick would work in the client and makes + // the behaviour untestable and a frame late. + Bind(page, NewButtonId, () => + { + CommitText(); + _bindings.Commands.NewPage(); + Refresh(); + }); Bind(page, FirstButtonId, () => Navigate(1)); Bind(page, LastButtonId, () => Navigate(_bindings.Journal.Snapshot.PageCount)); Bind(page, PreviousButtonId, @@ -172,7 +189,7 @@ public sealed class JournalNotesPageController ? string.Empty : $"~ {snapshot.CurrentPage.ToString(CultureInfo.InvariantCulture)} ~"); - SetText(_location, page.HasLocation + _location?.SetText(page.HasLocation ? FormatLocation(page.LocationX, page.LocationY) : string.Empty); @@ -210,6 +227,7 @@ public sealed class JournalNotesPageController { CommitText(); _bindings.Commands.GotoPage(pageNumber); + Refresh(); } private void RecordLocation() @@ -222,6 +240,7 @@ public sealed class JournalNotesPageController return; // indoors: retail's own gid_to_lcoord failure _bindings.Commands.RecordLocation((float)coordinates.X, (float)coordinates.Y); + Refresh(); } private void ToggleTimer() @@ -229,11 +248,13 @@ public sealed class JournalNotesPageController if (_bindings.Journal.RemainingTimerSeconds(_bindings.Now()) > 0d) { _bindings.Commands.ResetTimer(); + Refresh(); return; } CommitText(); // the fields the countdown reads _bindings.Commands.StartTimer(_bindings.Now()); + Refresh(); } /// diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 454be105..043aab3f 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -324,6 +324,8 @@ public sealed record QuestRuntimeBindings( AcDream.Runtime.Gameplay.IRuntimeJournalView Journal, AcDream.Runtime.Gameplay.RuntimeJournalState JournalCommands, Func PlayerCell, + /// Sends the abandon-contract action (0x0316). + Action AbandonContract, /// Where the per-character journal file lives. string JournalDirectory, /// How a load or save failure reaches the player. @@ -3553,7 +3555,8 @@ public sealed class RetailUiRuntime : IDisposable { lock (_bindings.Assets.DatLock) return rowTemplates.Resolve(templateLayoutId, templateElementId); - }), + }, + Abandon: _bindings.Quests.AbandonContract), Notes: new Layout.JournalNotesPageController.Bindings( Journal: _bindings.Quests.Journal, Commands: _bindings.Quests.JournalCommands, diff --git a/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs b/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs index 2acd6b42..4ee9d358 100644 --- a/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs +++ b/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs @@ -25,6 +25,7 @@ public static class ClientCommandRequests public const uint SetAfkMessageOpcode = 0x0010u; public const uint EmoteOpcode = 0x01DFu; public const uint AddFriendOpcode = 0x0018u; + public const uint AbandonContractOpcode = 0x0316u; public const uint RemoveFriendOpcode = 0x0017u; public const uint ClearFriendsOpcode = 0x0025u; public const uint ModifyCharacterSquelchOpcode = 0x0058u; @@ -143,6 +144,15 @@ public static class ClientCommandRequests public static byte[] BuildAddFriend(uint sequence, string name) => BuildString(sequence, AddFriendOpcode, name); + /// + /// Campaign QJ: abandon a tracked contract — game action 0x0316, + /// payload one contract id. The server answers with 0x0315 carrying + /// DeleteContract, which is why the client does not remove the row + /// itself (ACE: GameActionAbandonContract). + /// + public static byte[] BuildAbandonContract(uint sequence, uint contractId) => + BuildUInt32(sequence, AbandonContractOpcode, contractId); + public static byte[] BuildRemoveFriend(uint sequence, uint friendId) => BuildUInt32(sequence, RemoveFriendOpcode, friendId); diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 4801149b..c513e699 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -2597,6 +2597,17 @@ public sealed class WorldSession : IDisposable SendGameAction(ClientCommandRequests.BuildHouseQuery(seq)); } + /// + /// Abandon a tracked contract (0x0316). The row disappears when the server + /// answers with its own 0x0315 delete — the client never removes it + /// locally, so a refused abandon leaves the quest exactly where it was. + /// + public void SendAbandonContract(uint contractId) + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildAbandonContract(seq, contractId)); + } + /// Query the local character's played time (0x01C2). public void SendQueryAge() { diff --git a/tests/AcDream.App.Tests/UI/Layout/JournalContractsPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/JournalContractsPageControllerTests.cs index cfd1587e..3884b2a8 100644 --- a/tests/AcDream.App.Tests/UI/Layout/JournalContractsPageControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/JournalContractsPageControllerTests.cs @@ -485,6 +485,55 @@ public sealed class JournalContractsPageControllerTests Assert.NotEqual(unselected.DefaultColor, selected.DefaultColor); } + // ── Abandon (game action 0x0316) ──────────────────────────────────── + + [Fact] + public void AbandonSendsTheSelectedContractAndRemovesNothingLocally() + { + // The row disappears when the SERVER answers with its own 0x0315 + // delete. Removing it optimistically would vanish a quest the server + // refused to drop, and it would reappear on the next full table. + (UiElement page, _) = BuildPage(); + using var state = new RuntimeContractState(); + Track(state, 0x10u, stage: 2u); + var abandoned = new List(); + + var controller = new JournalContractsPageController( + page, + new JournalContractsPageController.Bindings( + Contracts: state.View, + Catalog: () => Catalog(Entry(0x10u, "First")), + Now: () => Now, + TemplateResolver: RowTemplate, + Abandon: abandoned.Add)); + + controller.AbandonSelected(); + + Assert.Equal(new[] { 0x10u }, abandoned.ToArray()); + Assert.Equal(1, state.View.Snapshot.ContractCount); + } + + [Fact] + public void AbandonWithNothingSelectedSendsNothing() + { + (UiElement page, _) = BuildPage(); + using var state = new RuntimeContractState(); + var abandoned = new List(); + + var controller = new JournalContractsPageController( + page, + new JournalContractsPageController.Bindings( + Contracts: state.View, + Catalog: () => ContractCatalog.Empty, + Now: () => Now, + TemplateResolver: RowTemplate, + Abandon: abandoned.Add)); + + controller.AbandonSelected(); + + Assert.Empty(abandoned); + } + [Fact] public void AProgressCounterRendersThroughTheAuthoredFormat() { diff --git a/tests/AcDream.App.Tests/UI/Layout/JournalNotesPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/JournalNotesPageControllerTests.cs new file mode 100644 index 00000000..6f1b1aaa --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/JournalNotesPageControllerTests.cs @@ -0,0 +1,283 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Core.Journal; +using AcDream.Runtime.Gameplay; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Campaign QJ slice QJ3: the journal's notes page. +/// +public sealed class JournalNotesPageControllerTests +{ + private static readonly DateTime Now = new(2026, 8, 21, 12, 0, 0, DateTimeKind.Utc); + + private const uint PreviousButtonId = 0x10000565u; + private const uint NextButtonId = 0x10000566u; + private const uint NewButtonId = 0x10000567u; + private const uint LabelFieldId = 0x10000569u; + private const uint TitleFieldId = 0x1000056Bu; + private const uint NotesFieldId = 0x1000056Du; + private const uint FirstButtonId = 0x1000056Fu; + private const uint PageNumberId = 0x10000570u; + private const uint LastButtonId = 0x10000571u; + private const uint LocationFieldId = 0x10000573u; + private const uint RecordButtonId = 0x10000574u; + private const uint TimerDaysFieldId = 0x10000576u; + private const uint TimerHoursFieldId = 0x10000578u; + private const uint TimerMinutesFieldId = 0x1000057Au; + private const uint RunningTimerTextId = 0x1000057Cu; + private const uint StartButtonId = 0x1000057Du; + + /// An outdoor landcell, so Record has coordinates to stamp. + private const uint OutdoorCell = 0xA9B4001Fu; + + private static UiField Field(uint id) => new() { ElementId = id, DatElementId = id, Width = 100f, Height = 18f }; + + private static UiText Text(uint id) => new() { DatElementId = id, Width = 120f, Height = 18f }; + + private static UiButton Button(uint id) => new( + new ElementInfo { Id = id, Type = 1, Width = 60, Height = 20 }, + static _ => (0u, 0, 0)) + { + DatElementId = id, + }; + + /// + /// The page, with each child built as the type the REAL layout produces — + /// the location is a field, not a text element. + /// + private static UiElement BuildPage() + { + var page = new UiPanel { Width = 300f, Height = 500f }; + foreach (uint id in new[] + { + LabelFieldId, TitleFieldId, NotesFieldId, LocationFieldId, + TimerDaysFieldId, TimerHoursFieldId, TimerMinutesFieldId, + }) + { + page.AddChild(Field(id)); + } + + page.AddChild(Text(PageNumberId)); + page.AddChild(Text(RunningTimerTextId)); + foreach (uint id in new[] + { + PreviousButtonId, NextButtonId, NewButtonId, + FirstButtonId, LastButtonId, RecordButtonId, StartButtonId, + }) + { + page.AddChild(Button(id)); + } + + return page; + } + + private static (JournalNotesPageController Controller, RuntimeJournalState State, + UiElement Page) Bind(Func? now = null, params JournalPage[] pages) + { + var state = new RuntimeJournalState(); + state.Load(pages); + UiElement page = BuildPage(); + + var controller = new JournalNotesPageController( + page, + new JournalNotesPageController.Bindings( + Journal: state.View, + Commands: state, + PlayerCell: () => OutdoorCell, + Now: now ?? (() => Now))); + + return (controller, state, page); + } + + private static void Click(UiElement page, uint id) + => (UiElement.FindDescendant(page, id) as UiButton)!.OnClick!(); + + private static UiField FieldOf(UiElement page, uint id) + => (UiField)UiElement.FindDescendant(page, id)!; + + private static string TextOf(UiElement page, uint id) + { + var text = UiElement.FindDescendant(page, id) as UiText; + return text?.LinesProvider?.Invoke().FirstOrDefault().Text ?? string.Empty; + } + + // ── Record ────────────────────────────────────────────────────────── + + [Fact] + public void RecordPutsTheLocationOnSCREENAndNotJustInTheModel() + { + // The readout is authored EDITABLE, so it builds as a UiField. Binding + // it as UiText yielded null and threw the write away: the value + // reached the model and the file, and the player saw nothing. + var (_, state, page) = Bind(pages: new JournalPage()); + + Click(page, RecordButtonId); + + Assert.True(state.View.Current.HasLocation); + Assert.NotEqual(string.Empty, FieldOf(page, LocationFieldId).Text); + state.Dispose(); + } + + [Fact] + public void RecordOnAnEmptyJournalDoesNothing() + { + // No page to record onto. Retail's own guard. + var (_, state, page) = Bind(); + + Click(page, RecordButtonId); + + Assert.Equal(string.Empty, FieldOf(page, LocationFieldId).Text); + state.Dispose(); + } + + // ── the timer ─────────────────────────────────────────────────────── + + [Fact] + public void StartingATimerSwapsTheFieldsForTheRunningReadout() + { + // The readout is authored at the SAME x as the three number boxes, so + // both visible at once overlaps illegibly. + var (_, state, page) = Bind(pages: new JournalPage()); + FieldOf(page, TimerHoursFieldId).SetText("1"); + + Click(page, StartButtonId); + + Assert.False(FieldOf(page, TimerDaysFieldId).Visible); + Assert.False(FieldOf(page, TimerHoursFieldId).Visible); + Assert.False(FieldOf(page, TimerMinutesFieldId).Visible); + Assert.True(UiElement.FindDescendant(page, RunningTimerTextId)!.Visible); + Assert.Equal("1h 0s", TextOf(page, RunningTimerTextId)); + state.Dispose(); + } + + [Fact] + public void StartWithNoDurationEnteredDoesNothing() + { + // Empty boxes mean no countdown; the strip must stay editable rather + // than swapping to a readout of nothing. + var (_, state, page) = Bind(pages: new JournalPage()); + + Click(page, StartButtonId); + + Assert.True(FieldOf(page, TimerDaysFieldId).Visible); + Assert.Equal(string.Empty, TextOf(page, RunningTimerTextId)); + state.Dispose(); + } + + [Fact] + public void TheRunningTimerCountsDownOnTick() + { + DateTime now = Now; + var (controller, state, page) = Bind(now: () => now, pages: new JournalPage()); + FieldOf(page, TimerHoursFieldId).SetText("1"); + Click(page, StartButtonId); + Assert.Equal("1h 0s", TextOf(page, RunningTimerTextId)); + + now = Now.AddMinutes(30); + controller.Tick(); + + Assert.Equal("30m 0s", TextOf(page, RunningTimerTextId)); + state.Dispose(); + } + + [Fact] + public void PressingStartAgainStopsTheCountdownAndRestoresTheFields() + { + var (_, state, page) = Bind(pages: new JournalPage()); + FieldOf(page, TimerHoursFieldId).SetText("1"); + Click(page, StartButtonId); + + Click(page, StartButtonId); + + Assert.True(FieldOf(page, TimerDaysFieldId).Visible); + Assert.Equal(string.Empty, TextOf(page, RunningTimerTextId)); + state.Dispose(); + } + + // ── navigation ────────────────────────────────────────────────────── + + [Fact] + public void NewAppendsAPageAndShowsIt() + { + var (_, state, page) = Bind(); + + Click(page, NewButtonId); + + Assert.Single(state.View.Pages); + Assert.Equal("~ 1 ~", TextOf(page, PageNumberId)); + state.Dispose(); + } + + [Fact] + public void EveryNavigationCommitsTheCurrentPageFirst() + { + // Retail calls SaveThisPage on the way out of all five navigation + // buttons. Without it, typing and then paging away eats the edit. + var (_, state, page) = Bind(pages: new[] { new JournalPage(), new JournalPage() }); + FieldOf(page, TitleFieldId).SetText("typed but not committed"); + + Click(page, NextButtonId); + + Assert.Equal("typed but not committed", state.View.Pages[0].Title); + state.Dispose(); + } + + [Fact] + public void FirstAndLastJumpToTheEnds() + { + var (_, state, page) = Bind( + pages: new[] { new JournalPage(), new JournalPage(), new JournalPage() }); + + Click(page, LastButtonId); + Assert.Equal("~ 3 ~", TextOf(page, PageNumberId)); + + Click(page, FirstButtonId); + Assert.Equal("~ 1 ~", TextOf(page, PageNumberId)); + state.Dispose(); + } + + [Fact] + public void PreviousAndNextStopAtTheEndsRatherThanWrapping() + { + var (_, state, page) = Bind(pages: new[] { new JournalPage(), new JournalPage() }); + + Click(page, PreviousButtonId); // already on page 1 + Assert.Equal("~ 1 ~", TextOf(page, PageNumberId)); + + Click(page, NextButtonId); + Click(page, NextButtonId); // already on the last + Assert.Equal("~ 2 ~", TextOf(page, PageNumberId)); + state.Dispose(); + } + + [Fact] + public void AnEmptyJournalShowsNoPageNumber() + { + // "~ 0 ~" would name a page that does not exist. + var (_, state, page) = Bind(); + + Assert.Equal(string.Empty, TextOf(page, PageNumberId)); + state.Dispose(); + } + + [Fact] + public void SwitchingPagesShowsThatPagesText() + { + var (_, state, page) = Bind(pages: new[] + { + new JournalPage(Title: "first", Notes: "one"), + new JournalPage(Title: "second", Notes: "two"), + }); + + Click(page, NextButtonId); + + Assert.Equal("second", FieldOf(page, TitleFieldId).Text); + Assert.Equal("two", FieldOf(page, NotesFieldId).Text); + state.Dispose(); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/JournalPanelBoundWidgetTypesTests.cs b/tests/AcDream.App.Tests/UI/Layout/JournalPanelBoundWidgetTypesTests.cs new file mode 100644 index 00000000..90cd0d5d --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/JournalPanelBoundWidgetTypesTests.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.IO; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Content; +using DatReaderWriter; +using DatReaderWriter.Options; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Every element the Journal panel's controllers bind must build as the widget +/// type they cast it to. +/// +/// +/// +/// A controller resolves children with FindDescendant(...) as UiText. +/// When the authored element is editable (0x16) it builds as a +/// instead, the cast yields null, and every later write +/// is silently discarded — the value reaches the model and the file, and never +/// appears on screen. +/// +/// +/// That is exactly what happened to the location readout, which is authored +/// EDITABLE and was being resolved as text. This sweep covers the whole panel +/// so the next one fails here instead of in front of a player. +/// +/// +[Trait("Lane", "InstalledDat")] +public sealed class JournalPanelBoundWidgetTypesTests +{ + private static readonly (uint Id, Type Expected, string Name)[] Bound = + [ + // Contracts page — all read-only. + (0x100005CFu, typeof(UiTemplateListBox), "contracts list"), + (0x100005DFu, typeof(UiText), "contract status value"), + (0x100005E0u, typeof(UiText), "contract contact"), + (0x100005E1u, typeof(UiText), "contract contact location"), + (0x100005E2u, typeof(UiText), "contract quest location"), + (0x100005DEu, typeof(UiText), "contract description"), + (0x100005E3u, typeof(UiText), "contract timed value"), + + // Notes page. + (0x10000569u, typeof(UiField), "journal label field"), + (0x1000056Bu, typeof(UiField), "journal title field"), + (0x1000056Du, typeof(UiField), "journal notes field"), + (0x10000570u, typeof(UiText), "journal page number"), + (0x10000573u, typeof(UiField), "journal location readout (AUTHORED EDITABLE)"), + (0x10000576u, typeof(UiField), "journal timer days"), + (0x10000578u, typeof(UiField), "journal timer hours"), + (0x1000057Au, typeof(UiField), "journal timer minutes"), + (0x1000057Cu, typeof(UiText), "journal running timer readout"), + + // Page list. + (0x10000583u, typeof(UiTemplateListBox), "page list"), + (0x10000587u, typeof(UiField), "page list search box"), + ]; + + private static ImportedLayout BuildPanel() + { + string? datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + if (string.IsNullOrWhiteSpace(datDir) || !Directory.Exists(datDir)) + { + datDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + } + + if (!Directory.Exists(datDir)) + { + Assert.Fail( + "Lane=InstalledDat requires an installed retail DAT directory; " + + "see docs/release-gate.md."); + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + + ElementInfo? root = LayoutImporter.ImportInfos( + adapter, + JournalPanelController.HostLayoutId, + JournalPanelController.SlotElementId); + Assert.NotNull(root); + + var strings = new DatStringResolver(adapter); + return LayoutImporter.Build(root!, _ => (0u, 0, 0), null, _ => null, strings.Resolve); + } + + [Fact] + public void EveryBoundElementBuildsAsTheWidgetItsControllerCastsItTo() + { + ImportedLayout layout = BuildPanel(); + + var wrong = new List(); + foreach ((uint id, Type expected, string name) in Bound) + { + UiElement? element = layout.FindElement(id); + if (element is null) + { + wrong.Add($"{name} (0x{id:X8}) is missing from the layout"); + continue; + } + + if (!expected.IsInstanceOfType(element)) + { + wrong.Add( + $"{name} (0x{id:X8}) built as {element.GetType().Name}, " + + $"controller expects {expected.Name}"); + } + } + + Assert.Empty(wrong); + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/ContractTrackerMessagesTests.cs b/tests/AcDream.Core.Net.Tests/Messages/ContractTrackerMessagesTests.cs index d029999a..81127b41 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/ContractTrackerMessagesTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/ContractTrackerMessagesTests.cs @@ -183,3 +183,25 @@ public sealed class ContractTrackerMessagesTests HashHeader(count: 60000, buckets: 256), Arrival)); } } + +/// +/// Campaign QJ: the outbound abandon action. +/// +public sealed class AbandonContractRequestTests +{ + [Fact] + public void TheAbandonPayloadIsTheContractIdAlone() + { + // ACE's GameActionAbandonContract reads exactly one uint32 and nothing + // else; a longer payload desyncs the whole game-action stream. + byte[] frame = ClientCommandRequests.BuildAbandonContract( + sequence: 7u, contractId: 0x1234u); + + // 0xF7B1 envelope, sequence, opcode, then the payload. + Assert.Equal(0xF7B1u, BinaryPrimitives.ReadUInt32LittleEndian(frame.AsSpan(0))); + Assert.Equal(7u, BinaryPrimitives.ReadUInt32LittleEndian(frame.AsSpan(4))); + Assert.Equal(0x0316u, BinaryPrimitives.ReadUInt32LittleEndian(frame.AsSpan(8))); + Assert.Equal(0x1234u, BinaryPrimitives.ReadUInt32LittleEndian(frame.AsSpan(12))); + Assert.Equal(16, frame.Length); + } +} From eadedaea9042166149c5aa02df4cfdad83dc77e7 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 18:29:58 +0200 Subject: [PATCH 41/43] fix(journal): the timer's unit labels hide with their boxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported as "1d 2h 51sh m" — the running countdown drawing straight through the d/h/m labels. The readout is authored across the same strip as the three number boxes, so the strip has to be one thing or the other; I hid the boxes and left their labels behind. Retail's ShowEditableTimer @0x00495770 toggles SIX elements, not three: m_pDaysEditBox AND m_pDaysStaticText, and the same for hours and minutes, plus the readout inverse. Reading the swap as "hide the inputs" instead of "hide the input ROWS" is what produced the overlap. Also settles the Record question the same round raised. Nothing was broken: indoors, retail's own gid_to_lcoord fails and nothing is recorded, and UpdateLocation @0x004958F0 only ever formats coordinates already stored — there is no "you are indoors" message in that function to port. The silence is faithful, and it is now commented as such rather than left looking like a gap. JournalPanelLiveBindTests is new and is the test that should have existed first: it builds the panel from the real DATs, constructs the controllers, and asserts every button actually receives an OnClick. Every other test so far checked either the layout or the logic — none of them proved the controller finds its elements in the real tree, which is where an id typo or a subtree assumption produces a panel where nothing responds and nothing fails. The temporary ACDREAM_PROBE_JOURNAL instrumentation is removed; the question it was added for is answered. Co-Authored-By: Claude Opus 5 --- .../UI/Layout/JournalNotesPageController.cs | 26 ++- .../Layout/JournalNotesPageControllerTests.cs | 13 ++ .../UI/Layout/JournalPanelLiveBindTests.cs | 182 ++++++++++++++++++ 3 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 tests/AcDream.App.Tests/UI/Layout/JournalPanelLiveBindTests.cs diff --git a/src/AcDream.App/UI/Layout/JournalNotesPageController.cs b/src/AcDream.App/UI/Layout/JournalNotesPageController.cs index 45787285..a0370003 100644 --- a/src/AcDream.App/UI/Layout/JournalNotesPageController.cs +++ b/src/AcDream.App/UI/Layout/JournalNotesPageController.cs @@ -45,6 +45,14 @@ public sealed class JournalNotesPageController private const uint TimerDaysFieldId = 0x10000576u; private const uint TimerHoursFieldId = 0x10000578u; private const uint TimerMinutesFieldId = 0x1000057Au; + + // The "d" / "h" / "m" unit labels. Retail's ShowEditableTimer @0x00495770 + // toggles each box AND its label — m_pDaysStaticText, m_pHoursStaticText, + // m_pMinutesStaticText — so the running readout, which is authored over + // the same strip, does not draw through them. + private const uint TimerDaysLabelId = 0x10000577u; + private const uint TimerHoursLabelId = 0x10000579u; + private const uint TimerMinutesLabelId = 0x1000057Bu; private const uint RunningTimerTextId = 0x1000057Cu; private const uint StartButtonId = 0x1000057Du; @@ -79,6 +87,9 @@ public sealed class JournalNotesPageController /// private readonly UiField? _location; private readonly UiText? _runningTimer; + private readonly UiElement? _timerDaysLabel; + private readonly UiElement? _timerHoursLabel; + private readonly UiElement? _timerMinutesLabel; private readonly UiButton? _start; private long _renderedRevision = -1; @@ -97,6 +108,9 @@ public sealed class JournalNotesPageController _pageNumber = UiElement.FindDescendant(page, PageNumberId) as UiText; _location = UiElement.FindDescendant(page, LocationTextId) as UiField; _runningTimer = UiElement.FindDescendant(page, RunningTimerTextId) as UiText; + _timerDaysLabel = UiElement.FindDescendant(page, TimerDaysLabelId); + _timerHoursLabel = UiElement.FindDescendant(page, TimerHoursLabelId); + _timerMinutesLabel = UiElement.FindDescendant(page, TimerMinutesLabelId); _start = UiElement.FindDescendant(page, StartButtonId) as UiButton; // The three timer boxes take digits only. Their authored 0x1E is 2, so @@ -210,9 +224,15 @@ public sealed class JournalNotesPageController double remaining = _bindings.Journal.RemainingTimerSeconds(_bindings.Now()); bool running = remaining > 0d; + // Each box AND its unit label, exactly the six elements retail toggles. + // Hiding only the boxes leaves "d h m" drawn underneath the readout, + // which is authored across the same strip. if (_timerDays is not null) _timerDays.Visible = !running; if (_timerHours is not null) _timerHours.Visible = !running; if (_timerMinutes is not null) _timerMinutes.Visible = !running; + if (_timerDaysLabel is not null) _timerDaysLabel.Visible = !running; + if (_timerHoursLabel is not null) _timerHoursLabel.Visible = !running; + if (_timerMinutesLabel is not null) _timerMinutesLabel.Visible = !running; if (_runningTimer is not null) _runningTimer.Visible = running; SetText(_runningTimer, running @@ -236,8 +256,12 @@ public sealed class JournalNotesPageController if (cell == 0u) return; + // Indoors, retail's own gid_to_lcoord fails and nothing is recorded — + // UpdateLocation @0x004958F0 only ever formats coordinates already + // stored, so there is no "you are indoors" message to port. Silence + // here is faithful, not an omission. if (!AcDream.Core.Ui.RadarCoordinates.TryFromCell(cell, out var coordinates)) - return; // indoors: retail's own gid_to_lcoord failure + return; _bindings.Commands.RecordLocation((float)coordinates.X, (float)coordinates.Y); Refresh(); diff --git a/tests/AcDream.App.Tests/UI/Layout/JournalNotesPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/JournalNotesPageControllerTests.cs index 6f1b1aaa..2404b662 100644 --- a/tests/AcDream.App.Tests/UI/Layout/JournalNotesPageControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/JournalNotesPageControllerTests.cs @@ -29,6 +29,9 @@ public sealed class JournalNotesPageControllerTests private const uint TimerDaysFieldId = 0x10000576u; private const uint TimerHoursFieldId = 0x10000578u; private const uint TimerMinutesFieldId = 0x1000057Au; + private const uint TimerDaysLabelId = 0x10000577u; + private const uint TimerHoursLabelId = 0x10000579u; + private const uint TimerMinutesLabelId = 0x1000057Bu; private const uint RunningTimerTextId = 0x1000057Cu; private const uint StartButtonId = 0x1000057Du; @@ -64,6 +67,9 @@ public sealed class JournalNotesPageControllerTests page.AddChild(Text(PageNumberId)); page.AddChild(Text(RunningTimerTextId)); + page.AddChild(Text(TimerDaysLabelId)); + page.AddChild(Text(TimerHoursLabelId)); + page.AddChild(Text(TimerMinutesLabelId)); foreach (uint id in new[] { PreviousButtonId, NextButtonId, NewButtonId, @@ -152,6 +158,12 @@ public sealed class JournalNotesPageControllerTests Assert.False(FieldOf(page, TimerMinutesFieldId).Visible); Assert.True(UiElement.FindDescendant(page, RunningTimerTextId)!.Visible); Assert.Equal("1h 0s", TextOf(page, RunningTimerTextId)); + + // The unit labels go with their boxes. Leaving them drew "d h m" + // through the readout: "1d 2h 51sh m". + Assert.False(UiElement.FindDescendant(page, TimerDaysLabelId)!.Visible); + Assert.False(UiElement.FindDescendant(page, TimerHoursLabelId)!.Visible); + Assert.False(UiElement.FindDescendant(page, TimerMinutesLabelId)!.Visible); state.Dispose(); } @@ -195,6 +207,7 @@ public sealed class JournalNotesPageControllerTests Click(page, StartButtonId); Assert.True(FieldOf(page, TimerDaysFieldId).Visible); + Assert.True(UiElement.FindDescendant(page, TimerDaysLabelId)!.Visible); Assert.Equal(string.Empty, TextOf(page, RunningTimerTextId)); state.Dispose(); } diff --git a/tests/AcDream.App.Tests/UI/Layout/JournalPanelLiveBindTests.cs b/tests/AcDream.App.Tests/UI/Layout/JournalPanelLiveBindTests.cs new file mode 100644 index 00000000..dcc1bf74 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/JournalPanelLiveBindTests.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.IO; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Content; +using AcDream.Core.Journal; +using AcDream.Runtime.Gameplay; +using DatReaderWriter; +using DatReaderWriter.Options; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// The controllers must actually WIRE the real panel. +/// +/// +/// Everything else so far tested the layout (do the widgets build right?) or +/// the controller against a hand-built page (does the logic work?). Neither +/// proves the controller finds its elements in the REAL tree — which is the +/// one step where an id typo or a subtree assumption silently produces a panel +/// where nothing responds. +/// +[Trait("Lane", "InstalledDat")] +public sealed class JournalPanelLiveBindTests +{ + private const uint OutdoorCell = 0xA9B4001Fu; + + private static ImportedLayout BuildPanel() + { + string? datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + if (string.IsNullOrWhiteSpace(datDir) || !Directory.Exists(datDir)) + { + datDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + } + + if (!Directory.Exists(datDir)) + Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory."); + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + ElementInfo? root = LayoutImporter.ImportInfos( + adapter, + JournalPanelController.HostLayoutId, + JournalPanelController.SlotElementId); + Assert.NotNull(root); + + var strings = new DatStringResolver(adapter); + return LayoutImporter.Build(root!, _ => (0u, 0, 0), null, _ => null, strings.Resolve); + } + + private static (JournalNotesPageController Controller, RuntimeJournalState State, + UiElement Page) BindNotes(ImportedLayout layout, Func? playerCell = null) + { + UiElement? page = layout.FindElement(JournalPanelController.NotesPageId); + Assert.NotNull(page); + + var state = new RuntimeJournalState(); + state.Load([new JournalPage()]); + + var controller = new JournalNotesPageController( + page!, + new JournalNotesPageController.Bindings( + Journal: state.View, + Commands: state, + PlayerCell: playerCell ?? (() => OutdoorCell), + Now: () => new DateTime(2026, 8, 21, 12, 0, 0, DateTimeKind.Utc))); + + return (controller, state, page!); + } + + [Fact] + public void TheNotesPageWiresEveryButtonItOwns() + { + // If FindDescendant misses one — wrong id, or the element is not in the + // subtree the controller searches — that button silently does nothing + // and nothing else fails. + ImportedLayout layout = BuildPanel(); + (_, RuntimeJournalState state, UiElement page) = BindNotes(layout); + + var unwired = new List(); + foreach ((uint id, string name) in new[] + { + (0x10000565u, "Previous"), (0x10000566u, "Next"), (0x10000567u, "New"), + (0x1000056Fu, "First"), (0x10000571u, "Last"), + (0x10000574u, "Record"), (0x1000057Du, "Start"), + }) + { + if (UiElement.FindDescendant(page, id) is not UiButton button) + unwired.Add($"{name} (0x{id:X8}) not found under the notes page"); + else if (button.OnClick is null) + unwired.Add($"{name} (0x{id:X8}) has no OnClick"); + } + + Assert.Empty(unwired); + state.Dispose(); + } + + [Fact] + public void RecordOnTheRealPageStampsAndDisplaysTheLocation() + { + ImportedLayout layout = BuildPanel(); + (_, RuntimeJournalState state, UiElement page) = BindNotes(layout); + + (UiElement.FindDescendant(page, 0x10000574u) as UiButton)!.OnClick!(); + + Assert.True(state.View.Current.HasLocation); + var readout = UiElement.FindDescendant(page, 0x10000573u) as UiField; + Assert.NotNull(readout); + Assert.NotEqual(string.Empty, readout!.Text); + state.Dispose(); + } + + [Fact] + public void RecordIndoorsDoesNothingBecauseThereAreNoCoordinates() + { + // retail's own gid_to_lcoord failure. Worth pinning because it looks + // identical to a broken button from the player's side. + ImportedLayout layout = BuildPanel(); + (_, RuntimeJournalState state, UiElement page) = + BindNotes(layout, playerCell: () => 0x01020304u); + + (UiElement.FindDescendant(page, 0x10000574u) as UiButton)!.OnClick!(); + + Assert.False(state.View.Current.HasLocation); + state.Dispose(); + } + + [Fact] + public void StartOnTheRealPageSwapsToTheRunningReadout() + { + ImportedLayout layout = BuildPanel(); + (_, RuntimeJournalState state, UiElement page) = BindNotes(layout); + + var hours = UiElement.FindDescendant(page, 0x10000578u) as UiField; + Assert.NotNull(hours); + hours!.SetText("1"); + + (UiElement.FindDescendant(page, 0x1000057Du) as UiButton)!.OnClick!(); + + Assert.False(hours.Visible); + UiElement? readout = UiElement.FindDescendant(page, 0x1000057Cu); + Assert.NotNull(readout); + Assert.True(readout!.Visible); + state.Dispose(); + } + + [Fact] + public void ThePageListWiresItsButtonsToo() + { + ImportedLayout layout = BuildPanel(); + UiElement? page = layout.FindElement(JournalPanelController.PageListPageId); + Assert.NotNull(page); + + var state = new RuntimeJournalState(); + state.Load([new JournalPage(Title: "one")]); + _ = new JournalPageListController( + page!, + new JournalPageListController.Bindings( + Journal: state.View, + Commands: state, + OpenPage: _ => { }, + TemplateResolver: (_, _) => null)); + + var unwired = new List(); + foreach ((uint id, string name) in new[] + { + (0x10000585u, "Delete"), (0x10000588u, "Reset"), + }) + { + if (UiElement.FindDescendant(page!, id) is not UiButton button) + unwired.Add($"{name} (0x{id:X8}) not found"); + else if (button.OnClick is null) + unwired.Add($"{name} (0x{id:X8}) has no OnClick"); + } + + Assert.Empty(unwired); + state.Dispose(); + } +} From 57084b53724a7d0b14eca1e910a63d8342afb051 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 18:31:25 +0200 Subject: [PATCH 42/43] docs(quest): Campaigns QT and QJ closed user-accepted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Journal panel is complete: contracts from the server, a per-character notebook, and its searchable index. The gate ledger is recorded rather than smoothed over, because the pattern is the point — four rounds, and every defect was one mistake wearing different clothes: an element bound as the wrong thing, or a binding never tested. One reported failure was not a defect at all; the character was indoors, where retail records nothing either. Co-Authored-By: Claude Opus 5 --- .../2026-08-21-contract-tracker-campaign.md | 4 +-- docs/plans/2026-08-21-journal-campaign.md | 27 ++++++++++++++++--- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/docs/plans/2026-08-21-contract-tracker-campaign.md b/docs/plans/2026-08-21-contract-tracker-campaign.md index 986c553b..7fa6bbe2 100644 --- a/docs/plans/2026-08-21-contract-tracker-campaign.md +++ b/docs/plans/2026-08-21-contract-tracker-campaign.md @@ -1,7 +1,7 @@ # Campaign QT — the contract tracker (H.3's client half) -**Status:** CODE-COMPLETE 2026-08-21. All six slices landed; the connected -user gate is owed. +**Status:** CLOSED USER-ACCEPTED 2026-08-21. All six slices landed and the +connected gate passed, together with Campaign QJ's two tabs. **Why now.** M4's demo scenario is "talk to an NPC, accept a quest, ... complete the quest." Everything in that sentence works today EXCEPT the player's ability diff --git a/docs/plans/2026-08-21-journal-campaign.md b/docs/plans/2026-08-21-journal-campaign.md index 413130c2..0f1d9ddd 100644 --- a/docs/plans/2026-08-21-journal-campaign.md +++ b/docs/plans/2026-08-21-journal-campaign.md @@ -1,9 +1,28 @@ # Campaign QJ — the Journal and Page List tabs -**Status:** CODE-COMPLETE 2026-08-21. All five slices landed, plus the first -connected round's three fixes (button property `0x0D`, the location readout's -widget type, refresh-at-the-click). Abandon is now wired too — it turned out to -have a real wire action after all. The connected re-gate is owed. Completes the panel Campaign QT mounted: QT +**Status:** CLOSED USER-ACCEPTED 2026-08-21. All five slices landed and the +connected gate passed. + +The gate took four rounds, and every defect it found was the same mistake in a +different place — an element bound as the wrong thing, or a binding never +tested: + +1. **Button property `0x0D`** read as "starts disabled", which killed every + button on the panel (register QJ-2). +2. **The location readout** is authored EDITABLE, so it is a `UiField`; bound + as `UiText` it silently discarded every write — the value reached the model + and the file and never the screen. +3. **Handlers deferred their redraw** to the next frame's `Tick` where retail + redraws at the click. +4. **The timer's unit labels** stayed visible behind the running readout, + because retail's `ShowEditableTimer` toggles each box AND its label. + +Round 3's "Record does nothing" turned out not to be a defect at all: the +character was indoors, where retail's own `gid_to_lcoord` fails and nothing is +recorded. Faithful, and now commented so it does not read as a gap. + +The durable outcome is `JournalPanelLiveBindTests` — see +`claude-memory/feedback_test_the_binding_seam.md`. Completes the panel Campaign QT mounted: QT shipped the Contracts tab and left the other two inert by design. **Scope:** retail's `gmJournalUI` (element type `0x10000048`, page From 6c79d35c0bca9d132a76c1c9a1b4d9db687780b8 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 18:32:12 +0200 Subject: [PATCH 43/43] docs(roadmap): H.3's client scope is complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The roadmap line read "122 EmoteType x 39 Trigger mini-VM", which is the SERVER's job — the retail client stores no quest flag, evaluates no emote, and is never told a flag changed. Reading that line as client work is what made H.3 look far larger than it was; measuring what we already had narrowed the remaining scope to the contract tracker alone. That, plus the Journal notebook and its index, shipped user-accepted today. The two stale "what you cannot do yet" rows are corrected: the quest tracker exists, and NPC dialogue works — the user confirmed it mid-campaign, which is what prompted measuring the scope in the first place. Co-Authored-By: Claude Opus 5 --- docs/plans/2026-04-11-roadmap.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 4f2cc180..b74034db 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -979,7 +979,7 @@ Research: R7 + R10 + R11 + UI slice 05. - **✓ SHIPPED — H.1 — Chat window.** UI panel + all 6 wire opcodes (Channel, Tell, System, HearSpeech, HearRangedSpeech, TurbineChat). Wire layer + panel + outbound input + holtburger inbound parity + combat translator all shipped across I.1-I.7 on 2026-04-25. Targets `AcDream.UI.Abstractions`; will be reskinned when D.2b's custom retail-look toolkit lands. - **H.2 — Allegiance.** Tree model + XP pass-up math + 5 allegiance chat channels + MOTD. See `r11-allegiance.md`. -- **H.3 — Emote scripts + quests + dialogs.** 122 EmoteType × 39 Trigger mini-VM. Contract tracker UI. NPC dialog rendered via chat with `` markup. See `r10-quest-dialogs.md`. +- **H.3 — Emote scripts + quests + dialogs.** **Client scope COMPLETE 2026-08-21** (Campaigns QT + QJ, both user-accepted). The "122 EmoteType × 39 Trigger mini-VM" in this line describes the SERVER's job: per `r10-quest-dialogs.md` §1.3 the retail client stores no quest flag, evaluates no emote, and is never told a flag changed. It learns about quests three ways — dialogue strings the server already formatted, generic error toasts, and the contract tracker. The first two shipped earlier; the tracker, plus the Journal notebook and its index, shipped as the three-tab Journal panel (`RetailPanelCatalog.Journal` = 25). Start at `claude-memory/project_quest_journal_panel.md`. - **H.4 — Character creation.** `0xE000002 CharGen` dat + 13 heritages + templates + appearance picker + preview renderer. See `r07-character-creation.md`. **Acceptance:** create a character from scratch, talk to an NPC, get + complete a quest, gain XP that passes up to the patron. @@ -2115,7 +2115,7 @@ OpenGL ceiling; revisit macOS only if a supported graphics backend is chosen. | Sliding along buildings / walls feels wrong | **Phase L.2c + L.2d** | | Roof edge / cliff / precipice blocks or slides wrong | **Phase L.2c** | | Crossing outdoor cell seams reports the wrong cell | **Phase L.2e** | -| Can't talk to NPCs | Basic select/use/give interaction works; full emote conversation/dialog systems remain **Phase H.3** | +| Can't talk to NPCs | NPC dialogue works (user-confirmed 2026-08-21); the emote VM behind it is the SERVER's, not ours | | Can't open a door | **FIXED** ✓ — object-use, animation, fading hooks, and collision transitions shipped | | Portals render as a rotating black disk | **FIXED** ✓ — DAT particles/effects and portal-space presentation shipped | | Chimneys have no smoke | **Phase E.3 SHIPPED** ✓ | @@ -2135,6 +2135,6 @@ OpenGL ceiling; revisit macOS only if a supported graphics backend is chosen. | No character creation — must use ACE admin | **Phase H.4** | | Sky is a flat color | **Phase G.1** (shipped; F7 cycles time, F10 cycles weather) | | Can't join allegiance | **Phase H.2** | -| No quest tracker | **Phase H.3** | +| ~~No quest tracker~~ | **SHIPPED 2026-08-21** — the Journal panel's Contracts tab (Campaign QT) | If you see something not on this list, add it here and assign a phase.