fix(journal): the timer's unit labels hide with their boxes

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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-21 18:29:58 +02:00
parent e35d9386e4
commit eadedaea90
3 changed files with 220 additions and 1 deletions

View file

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

View file

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

View file

@ -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;
/// <summary>
/// The controllers must actually WIRE the real panel.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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<uint>? 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<string>();
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<string>();
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();
}
}