fix(ui): button property 0x0D was never "disabled", and it killed every Journal button

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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-21 15:58:03 +02:00
parent c5cc8ae5fc
commit 73a04244e7
4 changed files with 84 additions and 4 deletions

View file

@ -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 `<Tell:IIDString:…>` 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` |
---

View file

@ -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();

View file

@ -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);
}

View file

@ -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<DatReaderWriter.DBObjs.LayoutDesc>()
.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