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