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