diff --git a/tests/OptiTime.Pharos.Tests/Fixtures/gui-input-coalescing-enabled.json b/tests/OptiTime.Pharos.Tests/Fixtures/gui-input-coalescing-enabled.json new file mode 100644 index 0000000..59bd190 --- /dev/null +++ b/tests/OptiTime.Pharos.Tests/Fixtures/gui-input-coalescing-enabled.json @@ -0,0 +1,27 @@ +{ + "ShaderOptimizations": true, + "BlurOptimizationEnabled": true, + "DynamicLightOptimizations": true, + "EntityAnimationOptimizations": true, + "ParticleOptimizations": true, + "ParticleViewDistanceScalingEnabled": true, + "OcclusionCullingOptimizations": true, + "AmbientSoundOptimizations": true, + "FlySoundOptimizations": true, + "BackgroundFpsLimiterEnabled": true, + "PreciseFramePacingEnabled": true, + "GuiManagerOptimizations": true, + "GuiManagerNoLinqEnabled": true, + "GuiManagerInputNoLinqEnabled": true, + "GuiManagerMouseMoveCoalescingEnabled": true, + "HandbookOptimizations": true, + "RecipeLookupOptimizations": true, + "WeatherWindOptimizations": true, + "TickingBlocksOptimizations": true, + "ShadowFarVegetationCullEnabled": true, + "EntityShadowDistanceCullEnabled": true, + "EntityInterpolationOptimizations": true, + "RepulseAgentsOptimizations": true, + "SuppressCompatibilityMessages": false, + "EnableProfiling": false +} diff --git a/tests/OptiTime.Pharos.Tests/GuiOptimizationTests.cs b/tests/OptiTime.Pharos.Tests/GuiOptimizationTests.cs index 64c5450..0e78fba 100644 --- a/tests/OptiTime.Pharos.Tests/GuiOptimizationTests.cs +++ b/tests/OptiTime.Pharos.Tests/GuiOptimizationTests.cs @@ -1,17 +1,22 @@ +using System.Text.Json; +using Vintagestory.API.Common; +using Vintagestory.API.Server; using Zaldaryon.Pharos.Assertions; using Zaldaryon.Pharos.Input; +using Zaldaryon.Pharos.UI; namespace OptiTime.Pharos.Tests; /// -/// Drives the GUI the way a player does, through the same input paths the mod -/// patches. +/// Drives the GUI through Vintage Story's hotkeys and mouse clicks while the +/// GUI render optimization is active. The optional input patch has its own +/// startup-configured scenario below. /// /// guimgr is the riskiest option in the mod and the one that has already /// broken once: v1.5.16 shipped because the GUI Manager optimization had been /// silently dead since 1.22.5, where a dynamic dispatch was replaced with typed /// field references that did not resolve. It patches eight input and render -/// entry points on GuiManager, plus a finalizer on +/// render entry points on GuiManager, plus a finalizer on /// GuiComposer.PostRender. /// /// A test that only asserts "the dialog opened" cannot tell a working patch from @@ -29,9 +34,9 @@ namespace OptiTime.Pharos.Tests; public class GuiOptimizationTests : OptiTimeScenarioBase { /// - /// Every dialog the GUI Manager renders has to survive the patch, and the - /// game's own hotkey is what opens it, so the patched - /// GuiManager.OnKeyDown prefix is on the path. + /// Every tested dialog has to survive the GUI render optimization. The + /// game's own hotkey opens and closes each dialog through the normal input + /// path; this class does not enable the optional input patch. /// /// A dialog that is unavailable in this play style is a skip, not a /// failure; one that opens and then will not close is a failure, because @@ -41,56 +46,61 @@ public class GuiOptimizationTests : OptiTimeScenarioBase public async Task DialogsOpenAndCloseThroughTheirOwnHotkeys() { await WarmUpAsync(); - await SetOptimizationAsync("guimgr", enabled: true); + await WithOptimizationStateRestoredAsync("guimgr", async () => + { + await SetOptimizationAsync("guimgr", enabled: true); - (string Dialog, string Fallback)[] dialogs = - [ - ("GuiDialogEscapeMenu", "escapemenudialog"), - ("GuiDialogInventory", "inventorydialog"), - ("GuiDialogCharacter", "characterdialog"), - ("GuiDialogWorldMap", "worldmap"), - ("GuiDialogHandbook", "handbook"), - ]; + (string Dialog, string Fallback)[] dialogs = + [ + ("GuiDialogEscapeMenu", "escapemenudialog"), + ("GuiDialogInventory", "inventorydialog"), + ("GuiDialogCharacter", "characterdialog"), + ("GuiDialogWorldMap", "worldmap"), + ]; - var opened = new List(); - var skipped = new List(); + var opened = new List(); + var skipped = new List(); - foreach ((string dialog, string fallback) in dialogs) - { - if (!await OpenDialogAsync(dialog, fallback)) + foreach ((string dialog, string fallback) in dialogs) { - skipped.Add(dialog); - continue; + if (!await OpenDialogAsync(dialog, fallback)) + { + skipped.Add(dialog); + continue; + } + + opened.Add(dialog); + + string hotkey = DialogHotkey(dialog) ?? fallback; + Assert.NotNull(Client!.Hotkeys.Get(hotkey)); + await Client.Hotkeys.TriggerAsync(hotkey); + await Session!.StepFramesAsync(10); + bool closed = !Client.Ui!.IsOpen(dialog); + + Assert.True(closed, + $"'{dialog}' did not close when its own hotkey '{hotkey}' was pressed " + + "through the patched GuiManager input path."); + Assert.False(Client.Ui.IsOpen(dialog), $"'{dialog}' is still open after closing it."); } - opened.Add(dialog); - - bool closed = Client!.Ui!.Close(dialog); - await Session!.StepFramesAsync(10); + Console.WriteLine($"opened: {string.Join(", ", opened)}"); + Console.WriteLine($"unavailable in this play style: {string.Join(", ", skipped)}"); - Assert.True(closed, - $"'{dialog}' would not close. GuiManager's OnFinalizeFrame prefix or its " + - "OnKeyDown prefix is changing how dialogs are dismissed."); - Assert.False(Client.Ui.IsOpen(dialog), $"'{dialog}' is still open after closing it."); - } - - Console.WriteLine($"opened: {string.Join(", ", opened)}"); - Console.WriteLine($"unavailable in this play style: {string.Join(", ", skipped)}"); - - Assert.True(opened.Count >= 2, - $"Only {opened.Count} dialog(s) opened at all ({string.Join(", ", opened)}). " + - "Too few of the GUI paths were exercised for this test to mean anything."); + Assert.True(opened.Count >= 2, + $"Only {opened.Count} dialog(s) opened at all ({string.Join(", ", opened)}). " + + "Too few of the GUI paths were exercised for this test to mean anything."); - AssertNoUnexpectedClientErrors("gui"); + AssertNoUnexpectedClientErrors("gui"); + }); } /// - /// With the patch off, the same dialogs must open and close. Comparing the - /// two states is what turns "the dialog worked" into "the patch did not - /// change the dialog". + /// Dialogs stay usable after writing the GUI setting off and on. Harmony + /// patches remain installed until restart, so this does not compare patched + /// behavior with vanilla behavior. /// [ClientServerScenario(TimeoutMs = 600_000)] - public async Task DialogsBehaveTheSameWithThePatchOff() + public async Task DialogsRemainFunctionalAcrossGuiSettingChanges() { await WarmUpAsync(); @@ -99,7 +109,7 @@ public async Task DialogsBehaveTheSameWithThePatchOff() { await SetOptimizationAsync("guimgr", enabled: false); - string[] unpatched = []; + string[] settingOff = []; foreach ((string dialog, string fallback) in new[] { ("GuiDialogEscapeMenu", "escapemenudialog"), @@ -108,22 +118,24 @@ public async Task DialogsBehaveTheSameWithThePatchOff() { if (!await OpenDialogAsync(dialog, fallback)) continue; - unpatched = [.. unpatched, dialog]; + settingOff = [.. settingOff, dialog]; await CloseDialogAsync(dialog); } + Assert.NotEmpty(settingOff); + await SetOptimizationAsync("guimgr", enabled: true); - string[] patched = []; - foreach (string dialog in unpatched) + string[] settingOn = []; + foreach (string dialog in settingOff) { - if (await OpenDialogAsync(dialog)) patched = [.. patched, dialog]; + if (await OpenDialogAsync(dialog)) settingOn = [.. settingOn, dialog]; await CloseDialogAsync(dialog); } - Assert.Equal(unpatched.Order(), patched.Order()); + Assert.Equal(settingOff.Order(), settingOn.Order()); } finally { @@ -143,83 +155,88 @@ public async Task OpenDialogsRenderInsideTheWindow() var client = Client ?? throw new InvalidOperationException("Pharos did not provide a client after warm-up."); var session = Session ?? throw new InvalidOperationException("Pharos did not provide a session after warm-up."); var ui = client.Ui ?? throw new InvalidOperationException("Pharos did not initialize the client UI."); - await SetOptimizationAsync("guimgr", enabled: true); + await WithOptimizationStateRestoredAsync("guimgr", async () => + { + await SetOptimizationAsync("guimgr", enabled: true); - await OpenDialogRequiredAsync("GuiDialogInventory", "inventorydialog"); - await session.StepFramesAsync(15); + await OpenDialogRequiredAsync("GuiDialogInventory", "inventorydialog"); + await session.StepFramesAsync(15); - Assert.True(ui.IsOpen("GuiDialogInventory"), - "The inventory dialog would not open at all."); + Assert.True(ui.IsOpen("GuiDialogInventory"), + "The inventory dialog would not open at all."); - try - { - DialogLayout layout = ui.Layout("GuiDialogInventory"); + try + { + DialogLayout layout = ui.Layout("GuiDialogInventory"); - PharosAssert.DialogOnScreen(client, "GuiDialogInventory"); - PharosAssert.NoOverlappingElements(layout); + PharosAssert.DialogOnScreen(client, "GuiDialogInventory"); + PharosAssert.NoOverlappingElements(layout); - Console.WriteLine( - $"inventory layout: {layout.Elements.Count} elements in " + - $"{layout.Composers.Count} composers, window {layout.ScreenWidth}x{layout.ScreenHeight}, " + - $"gui scale {layout.GuiScale}"); - } - finally - { - ui.Close("GuiDialogInventory"); - await session.StepFramesAsync(5); - } + Console.WriteLine( + $"inventory layout: {layout.Elements.Count} elements in " + + $"{layout.Composers.Count} composers, window {layout.ScreenWidth}x{layout.ScreenHeight}, " + + $"gui scale {layout.GuiScale}"); + } + finally + { + ui.Close("GuiDialogInventory"); + await session.StepFramesAsync(5); + } + }); } /// - /// Mouse input goes through OnMouseMove, OnMouseDown, - /// OnMouseUp and OnMouseMove's coalescing. Driving real mouse - /// movement over an open dialog exercises all of them; a coalescing bug - /// shows up as a click landing on the wrong element or not at all. + /// A virtual mouse click on the handbook category strip changes the active + /// category, and a click on its close button dismisses the dialog while the + /// GUI render optimization is active. The handbook focuses its search field + /// on open, so its single-letter hotkey is not a reliable close action here. /// [ClientServerScenario(TimeoutMs = 600_000)] - public async Task VirtualMouseInputReachesAnOpenDialog() + public async Task VirtualMouseClickChangesTheHandbookCategory() { await WarmUpAsync(); var client = Client ?? throw new InvalidOperationException("Pharos did not provide a client after warm-up."); var session = Session ?? throw new InvalidOperationException("Pharos did not provide a session after warm-up."); var ui = client.Ui ?? throw new InvalidOperationException("Pharos did not initialize the client UI."); - await SetOptimizationAsync("guimgr", enabled: true); + await WithOptimizationStateRestoredAsync("guimgr", async () => + { + await SetOptimizationAsync("guimgr", enabled: true); - await OpenDialogRequiredAsync("GuiDialogInventory", "inventorydialog"); - await session.StepFramesAsync(15); + await ui.Handbook.OpenAsync(); + await ui.Handbook.WaitUntilLoadedAsync(); + Assert.True(ui.IsOpen("GuiDialogSurvivalHandbook"), "The handbook dialog did not open."); - Assert.True(ui.IsOpen("GuiDialogInventory")); - try - { - InputSnapshot before = client.Input.Snapshot(); - Assert.True(before.IsIdle, - $"The virtual input controller started with {before.PressedKeys.Count} keys " + - "and scroll already set. A leftover input from a previous test would be " + - "delivered to this dialog."); - - // Move the mouse in a small sweep, then read the state back: the - // coalescing prefix must not swallow the position. - for (int i = 0; i < 10; i++) + try { - client.Input.InjectMouseMove(400 + i * 8, 300 + i * 4); - await session.StepFramesAsync(1); + IReadOnlyList tabs = ui.Tabs("GuiDialogSurvivalHandbook", "verticalTabs"); + Assert.True(tabs.Count > 1, + $"The handbook exposes only {tabs.Count} category tab(s), so a tab change cannot be tested."); + GuiTabInfo active = Assert.Single(tabs, tab => tab.Active); + GuiTabInfo target = tabs.First(tab => tab.Index != active.Index); + + ui.ClickTab("GuiDialogSurvivalHandbook", "verticalTabs", target.Index); + Assert.True(await StepUntilAsync( + () => ui.Tabs("GuiDialogSurvivalHandbook", "verticalTabs") + .Any(tab => tab.Index == target.Index && tab.Active), + maxFrames: 30), + $"A real virtual mouse click did not activate handbook category '{target.Name}'."); + + var closeButton = Assert.Single(ui.Elements("GuiDialogSurvivalHandbook"), + element => element.IsInteractive && + string.Equals(element.Text, "Close Handbook", StringComparison.Ordinal)); + (int closeX, int closeY) = closeButton.Center; + client.Input.Click(closeX, closeY); + Assert.True(await StepUntilAsync( + () => !ui.IsOpen("GuiDialogSurvivalHandbook"), + maxFrames: 30), + "A real virtual mouse click on Close Handbook did not dismiss the dialog."); } - - (int x, int y) = client.Input.GetMousePosition(); - Assert.True(x > 0 && y > 0, - $"After moving the mouse the virtual position is ({x}, {y}). " + - "The input never reached the client, so nothing below would test the patch."); - - client.Input.InjectScroll(-120); - await session.StepFramesAsync(3); - Assert.NotEqual(0, client.Input.GetScrollDelta()); - client.Input.ResetScrollDelta(); - } - finally - { - ui.Close("GuiDialogInventory"); - await session.StepFramesAsync(5); - } + finally + { + ui.Close("GuiDialogSurvivalHandbook"); + await session.StepFramesAsync(5); + } + }); } /// @@ -240,20 +257,28 @@ public async Task GuiComposerSurvivesRepeatedOpenAndClose() var session = Session ?? throw new InvalidOperationException("Pharos did not provide a session after warm-up."); var player = Player ?? throw new InvalidOperationException("Pharos did not provide a player after warm-up."); var ui = client.Ui ?? throw new InvalidOperationException("Pharos did not initialize the client UI."); - await SetOptimizationAsync("guimgr", enabled: true); - - for (int i = 0; i < 12; i++) + await WithOptimizationStateRestoredAsync("guimgr", async () => { - if (!await OpenDialogAsync("GuiDialogEscapeMenu", "escapemenudialog")) { await session.StepFramesAsync(5); } - await session.StepFramesAsync(3); - ui.Close("GuiDialogEscapeMenu"); - await session.StepFramesAsync(3); - } + await SetOptimizationAsync("guimgr", enabled: true); + + int openedCycles = 0; + for (int i = 0; i < 30; i++) + { + await OpenDialogRequiredAsync("GuiDialogEscapeMenu", "escapemenudialog"); + Assert.True(ui.IsOpen("GuiDialogEscapeMenu"), $"Escape menu cycle {i + 1} did not open a dialog."); + openedCycles++; + await session.StepFramesAsync(3); + ui.Close("GuiDialogEscapeMenu"); + await session.StepFramesAsync(3); + } + + Assert.Equal(30, openedCycles); - player.Gui.CloseAllDialogs(); - await session.StepFramesAsync(10); + player.Gui.CloseAllDialogs(); + await session.StepFramesAsync(10); - AssertNoUnexpectedClientErrors("gui"); + AssertNoUnexpectedClientErrors("gui"); + }); } /// @@ -266,30 +291,57 @@ public async Task GuiComposerSurvivesRepeatedOpenAndClose() public async Task GuiManagerReportsItsFastPathShare() { await WarmUpAsync(); - await SetOptimizationAsync("guimgr", enabled: true); + await WithOptimizationStateRestoredAsync("guimgr", async () => + { + await SetOptimizationAsync("guimgr", enabled: true); + await OpenDialogRequiredAsync("GuiDialogInventory", "inventorydialog"); - DiagCounters counters = await CaptureDiagAsync("guimgr"); - Assert.True(counters.Present, "The 'guimgr' diag module produced no dump."); + try + { + DiagCounters counters = await CaptureDiagAsync("guimgr"); + Assert.True(counters.Present, "The 'guimgr' diag module produced no dump."); - double calls = counters.Require(DiagCounter.RenderCalls, "guimgr"); - double fastPath = counters.Require(DiagCounter.NoLinqPath, "guimgr"); + double calls = counters.Require(DiagCounter.RenderCalls, "guimgr"); + double fastPath = counters.Require(DiagCounter.NoLinqPath, "guimgr"); - await WriteArtifactAsync(nameof(GuiOptimizationTests), "guimgr.txt", counters.Raw); + await WriteArtifactAsync(nameof(GuiOptimizationTests), "guimgr.txt", counters.Raw); - Console.WriteLine(counters.Raw); + Console.WriteLine(counters.Raw); - Assert.True(calls > 0, "The GUI Manager render prefix never ran."); - Assert.True(fastPath <= calls, - $"guimgr reports {fastPath} fast-path renders out of {calls} calls. " + - "More fast-path renders than total calls means the two counters are " + - "incremented on different code paths and at least one is wrong."); + Assert.True(calls > 0, "The GUI Manager render prefix never ran."); + Assert.True(fastPath > 0, + "The GUI Manager render fast path did not execute while an inventory dialog was open."); + Assert.True(fastPath <= calls, + $"guimgr reports {fastPath} fast-path renders out of {calls} calls. " + + "More fast-path renders than total calls means the two counters are " + + "incremented on different code paths and at least one is wrong."); + } + finally + { + Client!.Ui!.Close("GuiDialogInventory"); + await Session!.StepFramesAsync(5); + } + }); } + private string? DialogHotkey(string dialogName) => Client!.RunOnClientThread(() => + Player!.Gui.LoadedDialogs + .FirstOrDefault(dialog => dialog.GetType().Name == dialogName) + ?.ToggleKeyCombinationCode); +} + +[ServerWorld( + seed: OptiTimeConfig.WorldSeed, + playStyle: OptiTimeConfig.PlayStyle, + worldType: OptiTimeConfig.WorldType)] +[ClientSetting("viewDistance", OptiTimeConfig.ViewDistance)] +[PharosMods(OptiTimeConfig.ModAssembly)] +[DataFiles("Fixtures/gui-input-coalescing-enabled.json", To = "ModConfig/OptiTime.json", Side = EnumAppSide.Client)] +public class GuiInputCoalescingTests : OptiTimeScenarioBase +{ /// - /// The GUI Manager has a coalescing interval for mouse-move handling. Hammer - /// the input path harder than the interval and the coalescing must not drop - /// the click that follows: a click swallowed by coalescing is a button that - /// sometimes does nothing, which is the exact bug the option exists to fix. + /// Boots with the opt-in input patch and mouse-move coalescing enabled, + /// then verifies that a click remains actionable after a burst of moves. /// [ClientServerScenario(TimeoutMs = 600_000)] public async Task MouseMoveCoalescingDoesNotSwallowClicks() @@ -298,30 +350,45 @@ public async Task MouseMoveCoalescingDoesNotSwallowClicks() var client = Client ?? throw new InvalidOperationException("Pharos did not provide a client after warm-up."); var session = Session ?? throw new InvalidOperationException("Pharos did not provide a session after warm-up."); var ui = client.Ui ?? throw new InvalidOperationException("Pharos did not initialize the client UI."); - await SetOptimizationAsync("guimgr", enabled: true); - await OpenDialogRequiredAsync("GuiDialogInventory", "inventorydialog"); - await session.StepFramesAsync(15); + using (JsonDocument config = JsonDocument.Parse(await File.ReadAllTextAsync(ModConfigPath))) + { + Assert.True(config.RootElement.GetProperty("GuiManagerInputNoLinqEnabled").GetBoolean(), + "The scenario profile did not activate the startup-only GUI input patch."); + Assert.True(config.RootElement.GetProperty("GuiManagerMouseMoveCoalescingEnabled").GetBoolean(), + "The scenario profile did not enable mouse-move coalescing."); + } + + Assert.Contains("GUI Manager input optimization loaded", ClientLogText(), StringComparison.OrdinalIgnoreCase); + await SetOptimizationAsync("guimgr", enabled: true); + await ui.Handbook.OpenAsync(); + await ui.Handbook.WaitUntilLoadedAsync(); + Assert.True(ui.IsOpen("GuiDialogSurvivalHandbook"), "The handbook dialog did not open."); try { + IReadOnlyList tabs = ui.Tabs("GuiDialogSurvivalHandbook", "verticalTabs"); + Assert.True(tabs.Count > 1, + $"The handbook exposes only {tabs.Count} category tab(s), so a tab change cannot be tested."); + GuiTabInfo active = Assert.Single(tabs, tab => tab.Active); + GuiTabInfo target = tabs.First(tab => tab.Index != active.Index); + for (int i = 0; i < 200; i++) { client.Input.InjectMouseMove(200 + (i % 50), 200 + (i % 37)); } - await session.StepFramesAsync(3); - - client.Input.InjectMouseButton(VirtualMouseButton.Left, pressed: true); - await session.StepFramesAsync(2); - client.Input.InjectMouseButton(VirtualMouseButton.Left, pressed: false); - await session.StepFramesAsync(5); - - AssertNoUnexpectedClientErrors("gui"); + ui.ClickTab("GuiDialogSurvivalHandbook", "verticalTabs", target.Index); + Assert.True(await StepUntilAsync( + () => ui.Tabs("GuiDialogSurvivalHandbook", "verticalTabs") + .Any(tab => tab.Index == target.Index && tab.Active), + maxFrames: 30), + $"The click after the mouse-move burst did not activate handbook category '{target.Name}'."); + AssertNoUnexpectedClientErrors("gui input coalescing"); } finally { client.Input.Reset(); - ui.Close("GuiDialogInventory"); + ui.Close("GuiDialogSurvivalHandbook"); await session.StepFramesAsync(5); } } diff --git a/tests/OptiTime.Pharos.Tests/HandbookOptimizationTests.cs b/tests/OptiTime.Pharos.Tests/HandbookOptimizationTests.cs index 56715bd..4bc85c1 100644 --- a/tests/OptiTime.Pharos.Tests/HandbookOptimizationTests.cs +++ b/tests/OptiTime.Pharos.Tests/HandbookOptimizationTests.cs @@ -1,3 +1,6 @@ +using System.Reflection; +using Vintagestory.API.Client; +using Vintagestory.API.Common; using Zaldaryon.Pharos.Assertions; namespace OptiTime.Pharos.Tests; @@ -21,6 +24,27 @@ namespace OptiTime.Pharos.Tests; [PharosMods(OptiTimeConfig.ModAssembly)] public class HandbookOptimizationTests : OptiTimeScenarioBase { + /// + /// A fresh vanilla profile must not disable the Handbook optimization during + /// startup. The ModDB report requires the reporter's conflicting modlist to + /// reproduce exactly; this control only checks OptiTime's no-conflict boot. + /// + [ClientServerScenario(TimeoutMs = 600_000)] + public async Task HandbookOptimizationStartsEnabledWithoutConflictingMods() + { + await WarmUpAsync(); + + IReadOnlyDictionary states = await ReadStatusAsync(); + OptionState handbook = states["handbook"]; + + await WriteArtifactAsync(nameof(HandbookOptimizationTests), "handbook-startup.txt", + $"status={handbook}"); + + Assert.True(handbook.Enabled, + $"Handbook started disabled in the isolated vanilla profile. Status: {handbook}. " + + "The comment's third-party conflict still needs its exact modlist and boot log."); + } + /// /// The indexer has to have finished, over the whole world. /// @@ -73,206 +97,226 @@ await WriteArtifactAsync(nameof(HandbookOptimizationTests), "pages.txt", "thousands of collectibles, and an indexer that ran before assets finished " + $"loading produces a cache that misses on every lookup. The diag module says " + $"indexer={indexer}."); + Assert.Equal("ready", indexer); } /// - /// Opening the handbook and searching is what drives the cache. Once warm, - /// repeat searches must hit: a cache with a 0% hit rate is the exact bug - /// v1.5.9 shipped, where the snapshot was invalidated by an unstable world - /// accessor in the integrated server. + /// A real Handbook stack with indexed block drops must return the same + /// relationship repeatedly from the frozen cache. The page-text scenario + /// below separately verifies the Handbook UI with the optimization on and off. /// [ClientServerScenario(TimeoutMs = 900_000)] - public async Task HandbookCacheCachesRepeatedLookups() + public async Task HandbookBlockDropCacheReturnsRepeatedHits() { await WarmUpAsync(); - await SetOptimizationAsync("handbook", enabled: true); - - if (!Client!.Ui!.Handbook.IsAvailable) + await WithOptimizationStateRestoredAsync("handbook", async () => { - Assert.Fail( - "The handbook dialog is not registered on this client, so the handbook " + - "optimization cannot be exercised. Its Harmony prefix sits on " + - "ItemStack.GetHandbookInfo, which is only reachable through it."); - } - - await Client.Ui.Handbook.OpenAsync(); - await Client.Ui.Handbook.WaitUntilLoadedAsync(); - await Session!.StepFramesAsync(30); - - try - { - // Warm the cache by asking for extended item info, which is the - // path the mod prefixes: ItemStack.GetHandbookInfo. Searching the - // handbook does not go through it - the handbook already knows what - // its pages say - so searching alone proves nothing about the cache. - ItemStack[] samples = - [ - .. new[] - { - OptiTimeConfig.IronAxeCodes, - OptiTimeConfig.PlankCodes, - OptiTimeConfig.CobblestoneCodes, - OptiTimeConfig.StickCodes, - } - .Select(TryResolveStack) - .Where(s => s is not null)!, - ]; - - Assert.True(samples.Length > 0, - "None of the suite's sample item codes resolved in this client, so the " + - "handbook cache has nothing to look up."); - - for (int round = 0; round < 3; round++) + await SetOptimizationAsync("handbook", enabled: true); + DiagCounters readiness = await CaptureDiagAsync("handbook", frames: 1); + Assert.True(readiness.Present, "The 'handbook' diag module produced no dump."); + Assert.Equal("ready", readiness.Text(DiagCounter.Indexer)); + + await ResetDiagAsync("handbook"); + var client = Client!; + string[] lookup = client.RunOnClientThread(() => { - foreach (ItemStack stack in samples) + var clientApi = (ICoreClientAPI)client.Client.api; + Assembly mod = AppDomain.CurrentDomain.GetAssemblies() + .SingleOrDefault(assembly => assembly.GetName().Name == "OptiTime") + ?? throw new InvalidOperationException("The OptiTime assembly is not loaded in the client process."); + Type handbookType = mod.GetType("OptiTime.HandbookOptimization") + ?? throw new InvalidOperationException("The Handbook optimization type is missing."); + MethodInfo getDrops = handbookType.GetMethod( + "GetCachedBlockDrops", + BindingFlags.Public | BindingFlags.Static) + ?? throw new MissingMethodException(handbookType.FullName, "GetCachedBlockDrops"); + + List ReadDrops(ItemStack stack) => + (List?)getDrops.Invoke(null, [clientApi, stack]) + ?? throw new InvalidOperationException("The handbook index has not completed."); + + foreach (CollectibleObject collectible in client.Client.World.Collectibles) { - ItemTooltip tooltip = Client.Items.Tooltip(stack, extendedInfo: true); - Assert.False(string.IsNullOrWhiteSpace(tooltip.Title), - $"'{stack}' rendered no handbook title. The handbook is unusable, " + - "with or without the mod."); + IEnumerable? handbookStacks = collectible.GetHandBookStacks(clientApi); + if (handbookStacks is null) continue; + + foreach (ItemStack stack in handbookStacks) + { + List drops = ReadDrops(stack); + if (drops.Count == 0) continue; + + List repeatedDrops = ReadDrops(stack); + string[] firstCodes = [.. drops.Select(drop => drop.Collectible.Code.ToString())]; + string[] repeatedCodes = [.. repeatedDrops.Select(drop => drop.Collectible.Code.ToString())]; + Assert.Equal(firstCodes, repeatedCodes); + return new[] { $"stack={stack.Class}:{stack.Collectible.Code}" } + .Concat(firstCodes) + .ToArray(); + } } - await Session!.StepFramesAsync(5); - } - - DiagCounters counters = await CaptureDiagAsync("handbook", frames: 300); - Assert.True(counters.Present, "The 'handbook' diag module produced no dump."); + throw new InvalidOperationException( + "No registered Handbook stack returned an indexed block-drop relationship."); + }); + await RunCommandAsync($"{OptiTimeConfig.Command} diag handbook dump"); + DiagCounters counters = DiagReader.Parse( + DiagReader.Body(ClientLogText(), "handbook")); double lookups = counters.Require(DiagCounter.CacheLookups, "handbook"); double hits = counters.Require(DiagCounter.Hits, "handbook"); double misses = counters.Require(DiagCounter.Misses, "handbook"); - await WriteArtifactAsync(nameof(HandbookOptimizationTests), "handbook.txt", counters.Raw); + await WriteArtifactAsync(nameof(HandbookOptimizationTests), "handbook-cache.txt", + $"{string.Join('\n', lookup)}\n{counters.Raw}"); Console.WriteLine(counters.Raw); - Console.WriteLine( - lookups > 0 - ? $"handbook: {hits} hits out of {lookups} lookups " + - $"({misses} misses)" - : "handbook: no lookups recorded. The mod's prefix is on " + - "CollectibleBehaviorHandbookTextAndExtraInfo.GetHandbookInfo, which " + - "only runs for an item page - the pages this client opens by default " + - "are game-mechanic text pages. The cache hit rate is therefore " + - "unverified here; see the coverage gaps in the report."); - - if (lookups > 0) - { - Assert.True(hits > 0, - $"handbook: {hits} hits out of {lookups} lookups. A cache that never " + - "hits is strictly slower than no cache, because every lookup pays for " + - "the miss and then does the original work. That is the v1.5.9 " + - "regression."); - - Assert.Equal(lookups, hits + misses, 0); - } - } - finally - { - await Client!.Ui!.Handbook.CloseAsync(); - await Session!.StepFramesAsync(5); - } + Assert.True(lookups >= 2, "The Handbook block-drop cache was never queried."); + Assert.True(hits >= 2, $"Repeated access recorded only {hits} cache hits."); + Assert.Equal(lookups, hits + misses, 0); + }); } /// - /// Opening a page has to produce the same content with the cache on and off. - /// The cache returns lists of blocks, creatures and containers; a lookup - /// that misses the world accessor and falls back to a stale snapshot returns - /// plausible but wrong entries, which is invisible unless compared. + /// Opening two stack pages selected from the loaded page list has to + /// produce the same page text after saving the Handbook setting off and on. + /// Both samples run in one + /// client process, so the startup Harmony patch remains installed; this + /// compares only page text, not vanilla behavior, item-stack widgets, or + /// images. /// [ClientServerScenario(TimeoutMs = 900_000)] - public async Task HandbookPagesReadTheSameWithTheCacheOnAndOff() + public async Task HandbookPageTextRemainsStableAcrossSettingChanges() { await WarmUpAsync(); - - if (!Client!.Ui!.Handbook.IsAvailable) + await WithOptimizationStateRestoredAsync("handbook", async () => { - Assert.Fail("The handbook dialog is not registered on this client."); - } + if (!Client!.Ui!.Handbook.IsAvailable) + { + Assert.Fail("The handbook dialog is not registered on this client."); + } - var patched = new Dictionary(); - var unpatched = new Dictionary(); + var settingOn = new Dictionary(); + var settingOff = new Dictionary(); - // Page codes are taken from the game's own page list rather than - // hardcoded: the codes come from each collectible's asset, they change - // between game versions, and a made-up one fails with "the handbook has - // no page X", which says nothing about the mod. - IReadOnlyList all = await Client.Ui.Handbook.PagesAsync(); - string[] pages = [.. all - .Where(p => !string.IsNullOrWhiteSpace(p.Code)) - .Take(3) - .Select(p => p.Code)]; + // Page codes are taken from the game's own page list rather than + // hardcoded: the codes come from each collectible's asset, they change + // between game versions, and a made-up one fails with "the handbook has + // no page X", which says nothing about the mod. + string[] pages = await FindTwoStackPagesWithTextAsync(); - Assert.True(pages.Length > 0, - $"The handbook lists no pages at all ({all.Count} returned). Nothing to compare."); + Console.WriteLine($"comparing page text across saved setting values: {string.Join(", ", pages)}"); - Console.WriteLine($"comparing pages: {string.Join(", ", pages)}"); + foreach (bool settingEnabled in new[] { false, true }) + { + await SetOptimizationAsync("handbook", settingEnabled); + await ResetDiagAsync("handbook"); - foreach (bool enabled in new[] { false, true }) - { - await SetOptimizationAsync("handbook", enabled); + await Client.Ui.Handbook.OpenAsync(); + await Client.Ui.Handbook.WaitUntilLoadedAsync(); + await Session!.StepFramesAsync(20); + try + { + foreach (string page in pages) + { + await Client.Ui.Handbook.OpenPageAsync(page); + await Session!.StepFramesAsync(15); + + (settingEnabled ? settingOn : settingOff)[page] = Client.Ui.Handbook.PageText; + } + } + finally + { + await Client.Ui.Handbook.CloseAsync(); + await Session!.StepFramesAsync(5); + } + await RunCommandAsync($"{OptiTimeConfig.Command} diag handbook dump"); - await Client.Ui.Handbook.OpenAsync(); - await Client.Ui.Handbook.WaitUntilLoadedAsync(); - await Session!.StepFramesAsync(20); + if (settingEnabled) + { + DiagCounters counters = DiagReader.Parse( + DiagReader.Body(ClientLogText(), "handbook")); + Assert.True(counters.Require(DiagCounter.CacheLookups, "handbook") > 0, + "Opening stack pages with the cache on did not invoke the optimized target."); + } + } foreach (string page in pages) { - await Client.Ui.Handbook.OpenPageAsync(page); - await Session!.StepFramesAsync(15); + string? off = settingOff[page]; + string? on = settingOn[page]; - (enabled ? patched : unpatched)[page] = Client.Ui.Handbook.PageText; + Assert.False(string.IsNullOrWhiteSpace(off), + $"Page '{page}' rendered no text with the saved setting off."); + Assert.Equal(off, on); } - - await Client.Ui.Handbook.CloseAsync(); - await Session!.StepFramesAsync(5); - } - - foreach (string page in pages) - { - string? a = unpatched[page]; - string? b = patched[page]; - - Assert.False(string.IsNullOrWhiteSpace(a), - $"Page '{page}' rendered no text with the cache off."); - Assert.Equal(a, b); - } + }); } /// - /// The handbook is the largest dialog in the game and the one that - /// re-composes while scrolling. If its layout code is patched, a page list - /// that scrolls must still scroll, and elements must stay on screen. + /// The handbook dialog stays on screen without overlapping controls after a + /// search narrows the page list. /// [ClientServerScenario(TimeoutMs = 900_000)] - public async Task HandbookLayoutStaysOnScreenWhileBrowsing() + public async Task HandbookLayoutHasNoOverlapsAfterSearch() { await WarmUpAsync(); - await SetOptimizationAsync("handbook", enabled: true); - - await Client!.Ui!.Handbook.OpenAsync(); - if (!Client.Ui.IsOpen("GuiDialogHandbook")) + await WithOptimizationStateRestoredAsync("handbook", async () => { - return; - } + await SetOptimizationAsync("handbook", enabled: true); - try - { - PharosAssert.DialogOnScreen(Client, "GuiDialogHandbook"); + await Client!.Ui!.Handbook.OpenAsync(); + Assert.True(Client.Ui.Handbook.IsOpen, + "The handbook did not open, so its layout was not checked."); + + try + { + PharosAssert.DialogOnScreen(Client, "GuiDialogSurvivalHandbook"); - await Client.Ui.Handbook.SearchAsync("stone"); - await Session!.StepFramesAsync(20); + await Client.Ui.Handbook.SearchAsync("stone"); + await Session!.StepFramesAsync(20); - DialogLayout layout = Client.Ui.Layout("GuiDialogHandbook"); - PharosAssert.NoOverlappingElements( - layout, - include: e => e.Width > 4 && e.Height > 4); + DialogLayout layout = Client.Ui.Layout("GuiDialogSurvivalHandbook"); + PharosAssert.NoOverlappingElements( + layout, + include: e => e.Width > 4 && e.Height > 4); + + Console.WriteLine($"handbook layout: {layout.Elements.Count} elements"); + } + finally + { + Client.Ui.Close("GuiDialogSurvivalHandbook"); + await Session!.StepFramesAsync(5); + } + }); + } - Console.WriteLine($"handbook layout: {layout.Elements.Count} elements"); + private async Task FindTwoStackPagesWithTextAsync() + { + var handbook = Client!.Ui!.Handbook; + IReadOnlyList pages = await handbook.PagesAsync(); + var found = new List(capacity: 2); + + await handbook.OpenAsync(); + try + { + foreach (HandbookPageInfo page in pages.Where(page => page.Kind == "stack")) + { + await handbook.OpenPageAsync(page.Code); + await Session!.StepFramesAsync(15); + if (!string.IsNullOrWhiteSpace(handbook.PageText)) + { + found.Add(page.Code); + if (found.Count == 2) break; + } + } } finally { - Client.Ui.Close("GuiDialogHandbook"); + await handbook.CloseAsync(); await Session!.StepFramesAsync(5); } + + Assert.Equal(2, found.Count); + return [.. found]; } -} \ No newline at end of file +} diff --git a/tests/OptiTime.Pharos.Tests/HarnessPatchTests.cs b/tests/OptiTime.Pharos.Tests/HarnessPatchTests.cs index d99bd16..3ecd51c 100644 --- a/tests/OptiTime.Pharos.Tests/HarnessPatchTests.cs +++ b/tests/OptiTime.Pharos.Tests/HarnessPatchTests.cs @@ -27,8 +27,8 @@ public void PrefixDeliversPacketWhenTheSharedReceiveLockExists() Assert.False(HarnessPatches.UdpEnqueuePatch.Prefix(client, packet)); - Assert.Equal(1, HarnessPatches.Delivered); - Assert.Equal(0, HarnessPatches.Dropped); + Assert.Equal(1, HarnessPatches.EnqueueShimDelivered); + Assert.Equal(0, HarnessPatches.EnqueueShimDropped); Assert.Same(packet, Assert.Single(client.ReadMessage())); } @@ -97,8 +97,8 @@ public void PrefixCountsPacketAsDroppedWhenTheSharedReceiveLockIsMissing() Assert.False(HarnessPatches.UdpEnqueuePatch.Prefix(client, packet)); - Assert.Equal(0, HarnessPatches.Delivered); - Assert.Equal(1, HarnessPatches.Dropped); + Assert.Equal(0, HarnessPatches.EnqueueShimDelivered); + Assert.Equal(1, HarnessPatches.EnqueueShimDropped); FieldInfo receiveBuffer = typeof(DummyNetwork).GetField( "ClientReceiveBuffer", @@ -121,8 +121,8 @@ public void PrefixCountsConcurrentPacketDeliveriesWithoutLoss() Parallel.For(0, packetCount, _ => HarnessPatches.UdpEnqueuePatch.Prefix(client, new Packet_UdpPacket())); - Assert.Equal(packetCount, HarnessPatches.Delivered); - Assert.Equal(0, HarnessPatches.Dropped); + Assert.Equal(packetCount, HarnessPatches.EnqueueShimDelivered); + Assert.Equal(0, HarnessPatches.EnqueueShimDropped); Assert.Equal(packetCount, client.ReadMessage().Count()); } } diff --git a/tests/OptiTime.Pharos.Tests/HarnessPatches.cs b/tests/OptiTime.Pharos.Tests/HarnessPatches.cs index 6fe3e43..764114b 100644 --- a/tests/OptiTime.Pharos.Tests/HarnessPatches.cs +++ b/tests/OptiTime.Pharos.Tests/HarnessPatches.cs @@ -37,12 +37,16 @@ internal static class HarnessPatches { private static readonly object Gate = new(); private static readonly object PositionGate = new(); + private static readonly object PositionReceivePatchGate = new(); private static bool _installed; private static bool s_udpEnqueuePatchInstalled; + private static bool s_entityPositionReceivePatchInstalled; + private static int s_positionReceiveObserverErrorLogged; private static long s_delivered; private static long s_dropped; private static long s_trackedEntityId = -1; private static readonly ConcurrentQueue s_positionObservations = new(); + private static readonly ConcurrentQueue s_receivedPositions = new(); private static FieldInfo? s_networkField; private static FieldInfo? s_clientReceiveBufferField; @@ -56,10 +60,11 @@ internal static class HarnessPatches /// private static DummyNetwork? s_sharedUdp; - /// How many datagrams the patch delivered, and how many it dropped. - public static long Delivered => Interlocked.Read(ref s_delivered); + /// How many datagrams the test enqueue shim delivered. + public static long EnqueueShimDelivered => Interlocked.Read(ref s_delivered); - public static long Dropped => Interlocked.Read(ref s_dropped); + /// How many datagrams the test enqueue shim dropped. + public static long EnqueueShimDropped => Interlocked.Read(ref s_dropped); public static bool IsUdpEnqueuePatchInstalled => Volatile.Read(ref s_udpEnqueuePatchInstalled); @@ -72,13 +77,19 @@ public static int PendingPositionObservationCount } internal readonly record struct EntityPositionObservation(long X, long Y, long Z, int Tick); + internal readonly record struct ReceivedEntityPositionObservation(long X, long Y, long Z, int Tick); - /// Tracks position snapshots reaching the client's UDP receive queue. + public static bool IsEntityPositionReceivePatchInstalled => + Volatile.Read(ref s_entityPositionReceivePatchInstalled); + + /// Tracks snapshots at the client's interpolation receive handler. public static void TrackEntityPositions(long entityId) { + EnsureEntityPositionReceivePatch(); lock (PositionGate) { while (s_positionObservations.TryDequeue(out _)) { } + while (s_receivedPositions.TryDequeue(out _)) { } Volatile.Write(ref s_trackedEntityId, entityId); } } @@ -91,6 +102,7 @@ public static void StopTrackingEntityPositions(long entityId) if (Volatile.Read(ref s_trackedEntityId) != entityId) return; Volatile.Write(ref s_trackedEntityId, -1); while (s_positionObservations.TryDequeue(out _)) { } + while (s_receivedPositions.TryDequeue(out _)) { } } } @@ -104,6 +116,106 @@ public static IReadOnlyList PositionObservationsFor(l } } + public static IReadOnlyList ReceivedPositionsFor(long entityId) + { + lock (PositionGate) + { + return Volatile.Read(ref s_trackedEntityId) == entityId + ? s_receivedPositions.ToArray() + : Array.Empty(); + } + } + + private static void EnsureEntityPositionReceivePatch() + { + if (IsEntityPositionReceivePatchInstalled) return; + + lock (PositionReceivePatchGate) + { + if (s_entityPositionReceivePatchInstalled) return; + + try + { + MethodInfo? target = AccessTools.Method( + "EntityBehaviorInterpolatePosition:OnReceivedServerPos", + [typeof(bool), typeof(EnumHandling).MakeByRefType()]); + if (target is null) + { + Console.WriteLine( + "[OptiTime.Pharos] Interpolation receive observer not installed: " + + "the game target EntityBehaviorInterpolatePosition.OnReceivedServerPos was not found."); + return; + } + + new Harmony("optitime.pharos.harness").Patch( + target, + postfix: new HarmonyMethod( + typeof(EntityPositionReceivePatch), + nameof(EntityPositionReceivePatch.Postfix))); + Volatile.Write(ref s_entityPositionReceivePatchInstalled, true); + } + catch (Exception ex) + { + Console.WriteLine( + $"[OptiTime.Pharos] Interpolation receive observer not installed ({ex.Message})."); + } + } + } + + internal static class EntityPositionReceivePatch + { + public static void Postfix(object __instance) + { + long trackedEntityId = Volatile.Read(ref s_trackedEntityId); + if (trackedEntityId < 0) return; + + try + { + object? entity = AccessTools.Field(__instance.GetType(), "entity")?.GetValue(__instance); + if (entity is null) return; + + long entityId = Convert.ToInt64(ReadMember(entity, "EntityId")); + if (entityId != trackedEntityId) return; + + object? position = ReadMember(entity, "Pos"); + object? attributes = ReadMember(entity, "Attributes"); + if (position is null || attributes is null) return; + + Type positionType = position.GetType(); + double x = Convert.ToDouble(ReadMember(position, "X")); + double y = Convert.ToDouble(ReadMember(position, "Y")); + double z = Convert.ToDouble(ReadMember(position, "Z")); + int tick = Convert.ToInt32(AccessTools.Method(attributes.GetType(), "GetInt", [typeof(string)]) + ?.Invoke(attributes, ["tick"])); + + lock (PositionGate) + { + if (Volatile.Read(ref s_trackedEntityId) != entityId) return; + s_receivedPositions.Enqueue(new( + CollectibleNet.SerializeDoublePrecise(x), + CollectibleNet.SerializeDoublePrecise(y), + CollectibleNet.SerializeDoublePrecise(z), + tick)); + } + } + catch (Exception ex) + { + if (Interlocked.Exchange(ref s_positionReceiveObserverErrorLogged, 1) == 0) + { + Console.WriteLine( + $"[OptiTime.Pharos] Interpolation receive observer could not record a position ({ex.Message})."); + } + } + } + + private static object? ReadMember(object instance, string name) + { + PropertyInfo? property = AccessTools.Property(instance.GetType(), name); + if (property is not null) return property.GetValue(instance); + return AccessTools.Field(instance.GetType(), name)?.GetValue(instance); + } + } + /// /// Installs the patches. Called from a module initializer so they are in /// place before the first client boots, which is before the first packet @@ -200,6 +312,7 @@ private static void ResetCountersCore() { Volatile.Write(ref s_trackedEntityId, -1); while (s_positionObservations.TryDequeue(out _)) { } + while (s_receivedPositions.TryDequeue(out _)) { } } } @@ -260,7 +373,7 @@ private static void Deliver(object socket, Packet_UdpPacket packet) { // The buffer is shared with the reader. Without its lock, even // Enqueue would race ReadMessage, so fail closed and expose the - // loss through Dropped for the engine probe to catch. + // loss through EnqueueShimDropped for the engine probe to catch. Interlocked.Increment(ref s_dropped); return; } diff --git a/tests/OptiTime.Pharos.Tests/ModDbReportRelatedTests.cs b/tests/OptiTime.Pharos.Tests/ModDbReportRelatedTests.cs new file mode 100644 index 0000000..a9eb99f --- /dev/null +++ b/tests/OptiTime.Pharos.Tests/ModDbReportRelatedTests.cs @@ -0,0 +1,852 @@ +using System.Reflection; +using Vintagestory.API.Common; +using Vintagestory.API.Common.Entities; +using Vintagestory.API.Config; +using Vintagestory.API.MathTools; +using Vintagestory.API.Server; +using Zaldaryon.Pharos.Player; + +namespace OptiTime.Pharos.Tests; + +/// +/// Drives prepared vanilla outputs through the crafting result slot and their +/// real held-item use paths. Ingredients are loaded directly into the crafting +/// grid, so these cases exercise output transfer and use but do not reproduce +/// ingredient gathering or any reporter's third-party mod list/save. +/// +[ServerWorld( + seed: OptiTimeConfig.WorldSeed, + playStyle: "surviveandbuild", + worldType: "superflat")] +[ClientSetting("viewDistance", OptiTimeConfig.ViewDistance)] +[PharosMods(OptiTimeConfig.ModAssembly)] +public class ModDbReportRelatedTests : OptiTimeScenarioBase +{ + private const int HealingApplicationHoldFrames = 186; // 3.1 seconds at 60 Hz; vanilla requires 3 seconds. + private const int FirestarterIgnitionAttempts = 64; // Bound retries for vanilla's 25% ignition roll. + + /// + /// Crafts a vanilla bandage with the recipe cache on, turns the saved setting + /// off, then uses the prepared item. The live Harmony patch remains loaded + /// until restart, so this is not an unpatched control or a reproduction of + /// the reporter's mod list or save. + /// + [ClientServerScenario(TimeoutMs = 900_000)] + public async Task PreparedBandageRemainsUsableAfterRecipeSettingTurnsOff() + { + await WarmUpAsync(); + await WithOptimizationStateRestoredAsync("recipe", async () => + { + await EnsurePlayerOnGroundAsync(); + await VerifyHealingOutputAsync( + "game:bandage-clean", + hotbarSlot: 0, + recipeSettingEnabled: true, + turnRecipeSettingOffBeforeUse: true); + AssertNoUnexpectedClientErrors("Prepared bandage after changing recipe setting"); + }); + } + + [ClientServerScenario(TimeoutMs = 900_000)] + public async Task PreparedBandageRemainsUsableWithRecipeSettingOn() + { + await WarmUpAsync(); + await WithOptimizationStateRestoredAsync("recipe", async () => + { + await EnsurePlayerOnGroundAsync(); + await VerifyHealingOutputAsync("game:bandage-clean", hotbarSlot: 0, recipeSettingEnabled: true); + AssertNoUnexpectedClientErrors("Prepared bandage with recipe cache enabled"); + }); + } + + [ClientServerScenario(TimeoutMs = 900_000)] + public async Task PreparedPoulticeRemainsUsableWithinTheSameSession() + { + await WarmUpAsync(); + await WithOptimizationStateRestoredAsync("recipe", async () => + { + await EnsurePlayerOnGroundAsync(); + await VerifyHealingOutputAsync("game:poultice-linen-horsetail", hotbarSlot: 0, recipeSettingEnabled: true); + AssertNoUnexpectedClientErrors("Prepared poultice"); + }); + } + + /// + /// Uses the vanilla firestarter against a firepit loaded with four pieces + /// of firewood, matching the in-game construction instructions. The + /// vanilla stop handler has a random 25% ignition roll, so the test retries + /// actual client input until ignition succeeds or the bounded attempt count + /// is exhausted. + /// + [ClientServerScenario(TimeoutMs = 900_000)] + public async Task PreparedFirestarterIgnitesAFueledVanillaFirepitThroughClientInput() + { + await WarmUpAsync(); + await WithOptimizationStateRestoredAsync("recipe", async () => + { + await SetOptimizationAsync("recipe", enabled: true); + (ItemStack clientOutput, ItemStack serverOutput) = + await PrepareRecipeOutputForUseAsync("game:firestarter", hotbarSlot: 0, recipeSettingEnabled: true); + Assert.Equal("game:firestarter", clientOutput.Collectible.Code.ToString()); + Assert.Equal("game:firestarter", serverOutput.Collectible.Code.ToString()); + Assert.Equal(clientOutput.StackSize, serverOutput.StackSize); + Assert.Equal("game:firestarter", ServerActiveItemCode()); + + BlockPos firepit = await PlaceFueledFirepitAsync(); + Assert.True(await Session!.Blocks.AimAtAsync(firepit), + "The client could not select the fueled vanilla firepit."); + string initialState = ServerBlockCode(firepit); + string initialClientState = ClientBlockCode(firepit); + Assert.Contains("firepit", initialState, StringComparison.OrdinalIgnoreCase); + Assert.Equal(initialState, initialClientState); + Assert.False(initialState.EndsWith("firepit-lit", StringComparison.OrdinalIgnoreCase), + $"The firepit was already lit before the firestarter was used: {initialState}."); + string serverIgnitionState = ServerHost!.RunOnGameThread(() => + { + BlockEntity entity = ((ICoreServerAPI)ServerHost.Server.Api).World.BlockAccessor.GetBlockEntity(firepit) + ?? throw new InvalidOperationException("The fueled firepit block entity is unavailable on the server."); + return FirepitIgnitionState(entity, secondsIgniting: 0f); + }); + Assert.Equal("Ignitable", serverIgnitionState); + string firepitReadyState = ServerHost.RunOnGameThread(() => + { + BlockEntity entity = ((ICoreServerAPI)ServerHost.Server.Api).World.BlockAccessor.GetBlockEntity(firepit) + ?? throw new InvalidOperationException("The fueled firepit block entity is unavailable on the server."); + return FirepitIgnitionState(entity, secondsIgniting: 3.1f); + }); + Assert.Equal("IgniteNow", firepitReadyState); + + bool clientFuelSynced = await StepUntilAsync(() => Client!.RunOnClientThread(() => + { + BlockEntity? entity = Client.Client.World.BlockAccessor.GetBlockEntity(firepit); + return entity is not null && FirepitIgnitionState(entity, secondsIgniting: 0f) == "Ignitable"; + }), maxFrames: 30); + Assert.True(clientFuelSynced, + "The server's fueled firepit inventory never reached the client as an ignitable block."); + + int durabilityBefore = ServerActiveItemDurability(); + int attempts = 0; + var attemptResults = new List(); + for (; attempts < FirestarterIgnitionAttempts && + !ServerBlockCode(firepit).EndsWith("firepit-lit", StringComparison.OrdinalIgnoreCase); + attempts++) + { + Assert.True(await Session.Blocks.AimAtAsync(firepit), + "The client could not select the fueled vanilla firepit."); + Assert.Equal("game:firestarter", ServerActiveItemCode()); + bool interactionStarted = await UseActiveItemForDurationAsync( + "game:firestarter", holdFrames: 190, sneaking: true); + string activeItemAfter = ServerActiveItemCode() ?? ""; + string stateAfter = ServerBlockCode(firepit); + attemptResults.Add( + $"attempt={attempts + 1} started={interactionStarted} activeItem={activeItemAfter} state={stateAfter}"); + await WriteArtifactAsync(nameof(ModDbReportRelatedTests), "firepit-attempts.txt", + string.Join('\n', attemptResults) + "\n"); + Assert.True(interactionStarted, + $"The prepared firestarter did not begin a held-item interaction on attempt {attempts + 1}; " + + $"activeItem={activeItemAfter}, state={stateAfter}."); + Assert.Equal("game:firestarter", activeItemAfter); + } + + bool litAndSynced = await StepUntilAsync(() => + ServerBlockCode(firepit).EndsWith("firepit-lit", StringComparison.OrdinalIgnoreCase) && + ClientBlockCode(firepit).EndsWith("firepit-lit", StringComparison.OrdinalIgnoreCase), + maxFrames: 180); + int durabilityAfter = ServerActiveItemDurability(); + await WriteArtifactAsync(nameof(ModDbReportRelatedTests), "firepit-ignition.txt", + $"attempts={attempts}\n" + + "holdInputFramesPerAttempt=190\n" + + $"initialState={initialState}\n" + + $"initialClientState={initialClientState}\n" + + string.Join('\n', attemptResults) + "\n" + + $"serverState={ServerBlockCode(firepit)}\n" + + $"clientState={ClientBlockCode(firepit)}\n" + + $"durabilityBefore={durabilityBefore}\n" + + $"durabilityAfter={durabilityAfter}\n"); + Assert.True(litAndSynced, + $"The fueled firepit did not light on the server and client after {attempts} held interactions."); + Assert.True(durabilityAfter < durabilityBefore, + "The firestarter durability did not change after the vanilla firepit lit."); + AssertNoUnexpectedClientErrors("Prepared firestarter on a fueled vanilla firepit"); + }); + } + + [ClientServerScenario(TimeoutMs = 900_000)] + public async Task PreparedFirestarterIgnitesAnExtinctVanillaTorchThroughClientInput() + { + await WarmUpAsync(); + await WithOptimizationStateRestoredAsync("recipe", async () => + { + await SetOptimizationAsync("recipe", enabled: true); + (ItemStack clientOutput, ItemStack serverOutput) = + await PrepareRecipeOutputForUseAsync("game:firestarter", hotbarSlot: 0, recipeSettingEnabled: true); + Assert.Equal("game:firestarter", clientOutput.Collectible.Code.ToString()); + Assert.Equal("game:firestarter", serverOutput.Collectible.Code.ToString()); + Assert.Equal("game:firestarter", ServerActiveItemCode()); + + BlockPos torchPosition = await PlaceExtinctTorchAsync(); + string extinctState = ServerBlockCode(torchPosition); + Assert.Equal("game:torch-basic-extinct-up", extinctState); + string thresholdBefore = TorchIgnitionState(torchPosition, secondsIgniting: 1f); + string thresholdAfter = TorchIgnitionState(torchPosition, secondsIgniting: 1.1f); + Assert.Equal("Ignitable", thresholdBefore); + Assert.Equal("IgniteNow", thresholdAfter); + + int durabilityBefore = ServerActiveItemDurability(); + int attempts = 0; + for (; attempts < FirestarterIgnitionAttempts && + !ServerBlockCode(torchPosition).EndsWith("torch-basic-lit-up", StringComparison.OrdinalIgnoreCase); + attempts++) + { + Assert.True(await Session!.Blocks.AimAtAsync(torchPosition), + "The client could not select the extinct vanilla torch."); + bool interactionStarted = await UseActiveItemForDurationAsync( + "game:firestarter", holdFrames: 66); + Assert.True(interactionStarted, + $"The prepared firestarter did not begin a held-item interaction on torch attempt {attempts + 1}."); + } + + bool litAndSynced = await StepUntilAsync(() => + ServerBlockCode(torchPosition).EndsWith("torch-basic-lit-up", StringComparison.OrdinalIgnoreCase) && + ClientBlockCode(torchPosition).EndsWith("torch-basic-lit-up", StringComparison.OrdinalIgnoreCase), + maxFrames: 180); + int durabilityAfter = ServerActiveItemDurability(); + await WriteArtifactAsync(nameof(ModDbReportRelatedTests), "torch-ignition.txt", + $"attempts={attempts}\n" + + $"maxUseInputFramesPerAttempt=66\n" + + $"maxUseInputSecondsPerAttempt={66f / 60f:F3}\n" + + $"serverState={ServerBlockCode(torchPosition)}\n" + + $"clientState={ClientBlockCode(torchPosition)}\n" + + $"durabilityBefore={durabilityBefore}\n" + + $"durabilityAfter={durabilityAfter}\n"); + Assert.True(litAndSynced, + $"The extinct torch did not light on the server and client after {attempts} held interactions."); + Assert.True(durabilityAfter < durabilityBefore, + "The firestarter durability did not change after the vanilla torch lit."); + AssertNoUnexpectedClientErrors("Prepared firestarter on an extinct vanilla torch"); + }); + } + + [ClientServerScenario(TimeoutMs = 900_000)] + public async Task VanillaIronAnvilCanBePlacedThroughClientInput() + { + await WarmUpAsync(); + BlockPos ground = PlayerBlockPosition(2); + BlockPos anvil = ground.UpCopy(); + + Assert.True(await Session!.Blocks.PlaceAsync(ground, BlockFacing.UP, "game:anvil-iron"), + "The vanilla iron anvil did not place through the client's world interaction."); + Assert.Equal("game:anvil-iron", ServerBlockCode(anvil)); + Assert.Equal("game:anvil-iron", ClientBlockCode(anvil)); + Assert.NotNull(ServerHost!.RunOnGameThread(() => + ((ICoreServerAPI)ServerHost.Server.Api).World.BlockAccessor.GetBlockEntity(anvil))); + AssertNoUnexpectedClientErrors("Placing a vanilla iron anvil through the client"); + } + + [ClientServerScenario(TimeoutMs = 900_000)] + public async Task PreparedCopperScytheBreaksVanillaTallGrassWithoutReconnect() + { + await WarmUpAsync(); + await EnsurePlayerOnGroundAsync(); + var client = Client ?? throw new InvalidOperationException("Pharos did not provide a client for the scythe scenario."); + await WithOptimizationStateRestoredAsync("recipe", async () => + { + await SetOptimizationAsync("recipe", enabled: true); + (ItemStack clientOutput, ItemStack serverOutput) = + await PrepareRecipeOutputForUseAsync("game:scythe-copper", hotbarSlot: 0, recipeSettingEnabled: true); + Assert.Equal("game:scythe-copper", clientOutput.Collectible.Code.ToString()); + Assert.Equal("game:scythe-copper", serverOutput.Collectible.Code.ToString()); + Assert.Equal(clientOutput.StackSize, serverOutput.StackSize); + Assert.Equal("game:scythe-copper", ServerActiveItemCode()); + + Assert.Equal(0, ClientActiveItemToolMode()); + Assert.Equal(0, ServerActiveItemToolMode()); + Assert.NotNull(Client!.Hotkeys.Get("toolmodeselect")); + await Client.Hotkeys.TriggerAsync("toolmodeselect"); + Assert.True(Client.Ui!.IsOpen("GuiDialogToolMode"), + "The normal tool-mode hotkey did not open the vanilla scythe mode selector."); + var toolModeGrid = Client.Ui.Find("GuiDialogToolMode", "skillitemgrid-0"); + Assert.NotNull(toolModeGrid); + Assert.True(toolModeGrid.IsInteractive && toolModeGrid.Width > 1 && toolModeGrid.Height > 1, + "The vanilla tool-mode grid is not available for a real mouse click."); + Client.Input.Click( + (int)(toolModeGrid.X + toolModeGrid.Width * 0.75), + (int)(toolModeGrid.Y + toolModeGrid.Height / 2)); + bool removeModeSynced = await StepUntilAsync( + () => ClientActiveItemToolMode() == 1 && ServerActiveItemToolMode() == 1, + maxFrames: 120); + Assert.True(removeModeSynced, + $"Selecting remove-grass mode through the vanilla dialog did not synchronize to both peers; " + + $"client={ClientActiveItemToolMode()}, server={ServerActiveItemToolMode()}."); + + BlockPos grass = await PlaceTallGrassNearPlayerAsync(); + Assert.Equal("game:tallgrass-medium-free", ServerBlockCode(grass)); + Vec3d grassAimPoint = new(grass.X + 0.5, grass.Y + 0.125, grass.Z + 0.5); + bool grassSelected = await StepUntilAsync(() => client.RunOnClientThread(() => + { + client.TestPlayer.Camera.LookAt(grassAimPoint); + return client.Client.BlockSelection?.Position?.Equals(grass) == true; + }), maxFrames: 120); + Assert.True(grassSelected, + "The client could not select the vanilla tall grass at a point inside its 0.25-block selection box."); + + client.Controls.Press(PlayerAction.Attack); + bool brokeOnClient; + try + { + brokeOnClient = await StepUntilAsync( + () => ClientBlockCode(grass) == "game:air", + maxFrames: 600); + } + finally + { + client.Controls.Release(PlayerAction.Attack); + } + Assert.True(brokeOnClient, + "The vanilla client did not break the selected tall grass with the prepared copper scythe."); + + bool removalSynced = await StepUntilAsync( + () => ServerBlockCode(grass) == "game:air" && ClientBlockCode(grass) == "game:air", + maxFrames: 180); + await WriteArtifactAsync(nameof(ModDbReportRelatedTests), "scythe-use.txt", + $"item={ServerActiveItemCode()}\n" + + $"toolMode={ServerActiveItemToolMode()}\n" + + $"blockAfter={ServerBlockCode(grass)}\n" + + $"clientBlockAfter={ClientBlockCode(grass)}\n"); + + Assert.True(removalSynced, + "Breaking tall grass with the prepared copper scythe did not synchronize to client and server."); + AssertNoUnexpectedClientErrors("Prepared copper scythe on vanilla tall grass"); + }); + } + + private async Task VerifyHealingOutputAsync( + string outputCode, + int hotbarSlot, + bool recipeSettingEnabled, + bool turnRecipeSettingOffBeforeUse = false) + { + await SetOptimizationAsync("recipe", recipeSettingEnabled); + (ItemStack clientOutput, ItemStack serverOutput) = + await PrepareRecipeOutputForUseAsync(outputCode, hotbarSlot, recipeSettingEnabled); + Assert.Equal(outputCode, clientOutput.Collectible.Code.ToString()); + Assert.Equal(outputCode, serverOutput.Collectible.Code.ToString()); + Assert.Equal(clientOutput.StackSize, serverOutput.StackSize); + Assert.Equal(outputCode, ServerActiveItemCode()); + + if (turnRecipeSettingOffBeforeUse) + { + await SetOptimizationAsync("recipe", enabled: false); + } + + int countBefore = ServerActiveItemCount(); + DamageServerPlayer(3); + await Session!.StepFramesAsync(5); + float healthBefore = ServerHealth(); + float clientHealthBefore = ClientHealth(); + int clientLogCountBefore = Client!.Logs.Entries.Count; + Assert.True(ServerPlayerIsOnGround(), + $"The loopback player left the ground before applying '{outputCode}'."); + + bool interactionStarted = await UseActiveItemForDurationAsync( + outputCode, + holdFrames: HealingApplicationHoldFrames); + bool healed = await StepUntilAsync(() => ServerHealth() > healthBefore, maxFrames: 600); + await Session.StepFramesAsync(5); + string recentClientLog = string.Join('\n', Client.Logs.Entries + .Skip(clientLogCountBefore) + .Select(entry => entry.Message)); + + int remaining = ServerActiveItemCount(); + Assert.True(remaining < countBefore, + $"Using prepared '{outputCode}' did not consume its active stack " + + $"(recipeSettingAtCraft={recipeSettingEnabled}, turnedOffBeforeUse={turnRecipeSettingOffBeforeUse}, " + + $"before={countBefore}, after={remaining})."); + Assert.True(interactionStarted, + $"Using '{outputCode}' did not start the held-item interaction " + + $"(recipeSettingAtCraft={recipeSettingEnabled}, turnedOffBeforeUse={turnRecipeSettingOffBeforeUse})."); + Assert.True(healed, + $"Using prepared '{outputCode}' did not increase server health " + + $"(recipeSettingAtCraft={recipeSettingEnabled}, turnedOffBeforeUse={turnRecipeSettingOffBeforeUse}) " + + $"from {healthBefore:F2}."); + Assert.Contains("Gained", recentClientLog, StringComparison.OrdinalIgnoreCase); + Assert.Contains("through heal", recentClientLog, StringComparison.OrdinalIgnoreCase); + Assert.Equal(remaining, ClientActiveItemCount()); + float clientHealthAfter = ClientHealth(); + Assert.True(clientHealthAfter > clientHealthBefore, + $"The client health did not increase after using '{outputCode}' " + + $"(before={clientHealthBefore:F2}, after={clientHealthAfter:F2})."); + Assert.True(Math.Abs(clientHealthAfter - ServerHealth()) < 0.05f, + $"Client and server health did not synchronize after using '{outputCode}'."); + } + + private async Task UseActiveItemForDurationAsync(string outputCode, int holdFrames, bool sneaking = false) + { + if (sneaking) + { + Client!.Controls.Press(PlayerAction.Sneak); + } + Client!.Controls.Press(PlayerAction.Use); + bool started = false; + try + { + await Session!.StepFramesAsync(5); + started = Client.RunOnClientThread(() => + Client.Client.EntityPlayer.Controls.HandUse == EnumHandInteract.HeldItemInteract); + await Session.StepFramesAsync(holdFrames - 5); + } + finally + { + Client.Controls.Release(PlayerAction.Use); + if (sneaking) + { + Client.Controls.Release(PlayerAction.Sneak); + } + await Session!.StepFramesAsync(10); + } + + bool completed = await StepUntilAsync( + () => Client!.RunOnClientThread(() => + Client.Client.EntityPlayer.Controls.HandUse == EnumHandInteract.None), + maxFrames: 60); + Assert.True(completed, + $"Using '{outputCode}' did not leave the held-item interaction state after releasing input."); + return started; + } + + private ItemStack GenerateClientRecipeOutput(string outputCode) => Client!.RunOnClientThread(() => + { + IInventory grid = Player!.Inventory.Crafting + ?? throw new InvalidOperationException("The client crafting grid is unavailable."); + IPlayer player = Client.Client.player + ?? throw new InvalidOperationException("The client player is unavailable for recipe matching."); + return GenerateRecipeOutput(Client.Client.World, grid, player, outputCode, requireCacheHit: true); + }); + + private async Task<(ItemStack Client, ItemStack Server)> PrepareRecipeOutputForUseAsync( + string outputCode, + int hotbarSlot, + bool recipeSettingEnabled) + { + if (recipeSettingEnabled) + { + await ResetDiagAsync("recipe"); + } + + ItemStack clientOutput = GenerateClientRecipeOutput(outputCode); + if (recipeSettingEnabled) + { + await RunCommandAsync($"{OptiTimeConfig.Command} diag recipe dump"); + DiagCounters counters = DiagReader.Parse(DiagReader.Body(ClientLogText(), "recipe")); + Assert.True(counters.Require(DiagCounter.Hits, "recipe") > 0, + $"The client did not record a cache hit while preparing '{outputCode}'."); + } + + ItemStack serverOutput = GenerateServerRecipeOutput(outputCode); + await OpenDialogRequiredAsync("GuiDialogInventory", "inventorydialog"); + Client!.Ui!.ClickSlot("GuiDialogInventory", "outputslot", 0); + await Session!.StepFramesAsync(10); + Client.Ui.ClickSlot("HudHotbar", "hotbargrid", hotbarSlot); + await Session.StepFramesAsync(10); + Client.Ui.Close("GuiDialogInventory"); + await Session.StepFramesAsync(5); + // Slot clicks leave the virtual pointer over the HUD hotbar. Held-item + // input would hit that GUI again until the pointer returns to the world. + Client.Input.InjectMouseMove(640, 360); + await WaitForActiveItemAsync(outputCode); + Assert.Equal(ServerActiveItemCount(), ClientActiveItemCount()); + return (clientOutput, serverOutput); + } + + private ItemStack GenerateServerRecipeOutput(string outputCode) => ServerHost!.RunOnGameThread(() => + { + ICoreServerAPI api = (ICoreServerAPI)ServerHost.Server.Api; + IServerPlayer player = ServerHost.Server.GetClientByPlayername(PlayerName)?.Player + ?? throw new InvalidOperationException("The loopback player is unavailable on the server."); + IInventory grid = player.InventoryManager.GetOwnInventory(GlobalConstants.craftingInvClassName) + ?? throw new InvalidOperationException("The server crafting grid is unavailable."); + + _ = GenerateRecipeOutput(api.World, grid, player, outputCode, requireCacheHit: false); + + ItemSlot output = grid[grid.Count - 1] + ?? throw new InvalidOperationException("The server crafting output slot is unavailable."); + ItemStack crafted = output.Itemstack?.Clone() + ?? throw new InvalidOperationException($"Vanilla produced no output for '{outputCode}'."); + + return crafted; + }); + + private static ItemStack GenerateRecipeOutput( + IWorldAccessor world, + IInventory grid, + IPlayer player, + string outputCode, + bool requireCacheHit) + { + GridRecipe[] recipes = world.GridRecipes.Where(candidate => + string.Equals( + candidate.Output?.ResolvedItemStack?.Collectible.Code.ToString(), + outputCode, + StringComparison.OrdinalIgnoreCase)) + .OrderBy(candidate => candidate.Shapeless) + .ToArray(); + Assert.NotEmpty(recipes); + + Assert.True(grid.Count >= 10, + $"The live crafting grid has {grid.Count} slots; a 3x3 grid and output slot are required."); + + MethodInfo findMatchingRecipe = grid.GetType().GetMethod( + "FindMatchingRecipe", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + ?? throw new MissingMethodException(grid.GetType().FullName, "FindMatchingRecipe"); + + GridRecipe? recipe = null; + var rejected = new List(); + + foreach (GridRecipe candidate in recipes) + { + if (candidate.ResolvedIngredients is not { Length: > 0 } ingredients) + { + rejected.Add($"{candidate.Name}: no resolved inputs"); + continue; + } + + for (int slotIndex = 0; slotIndex < 9; slotIndex++) + { + grid[slotIndex]!.Itemstack = null; + } + + bool usable = true; + for (int i = 0; i < ingredients.Length; i++) + { + CraftingRecipeIngredient? ingredient = ingredients[i]; + if (ingredient is null) + { + continue; + } + + ItemStack? ingredientStack = ResolveRecipeIngredientStack(world, ingredient); + if (ingredientStack is null) + { + usable = false; + rejected.Add($"{candidate.Name}: input {i} has no stack satisfying {ingredient}"); + break; + } + + int gridIndex = candidate.Shapeless + ? i + : (i / candidate.Width) * 3 + (i % candidate.Width); + grid[gridIndex]!.Itemstack = ingredientStack; + grid[gridIndex]!.MarkDirty(); + } + + if (!usable) + { + continue; + } + + ItemSlot[] inputSlots = Enumerable.Range(0, 9).Select(index => grid[index]!).ToArray(); + if (!candidate.Matches(player, world, inputSlots, 3)) + { + string inputs = string.Join(", ", inputSlots + .Where(slot => slot.Itemstack is not null) + .Select(slot => $"{slot.Itemstack!.Collectible.Code}x{slot.Itemstack.StackSize}")); + rejected.Add($"{candidate.Name}: vanilla Matches rejected [{inputs}]"); + continue; + } + + recipe = candidate; + break; + } + + if (recipe is null) + { + throw new InvalidOperationException( + $"No loaded recipe for '{outputCode}' could be prepared through vanilla matching: " + + string.Join("; ", rejected)); + } + + findMatchingRecipe.Invoke(grid, null); + ItemStack first = grid[grid.Count - 1]!.Itemstack?.Clone() + ?? throw new InvalidOperationException( + $"The live recipe grid rejected '{recipe.Name}' for '{outputCode}' after vanilla Matches accepted it."); + + if (requireCacheHit) + { + findMatchingRecipe.Invoke(grid, null); + } + + ItemStack result = grid[grid.Count - 1]!.Itemstack?.Clone() + ?? throw new InvalidOperationException($"The recipe cache removed output '{outputCode}'."); + Assert.Equal(outputCode, result.Collectible.Code.ToString()); + Assert.Equal(first.StackSize, result.StackSize); + return result; + } + + private static ItemStack? ResolveRecipeIngredientStack( + IWorldAccessor world, + CraftingRecipeIngredient ingredient) + { + if (ingredient.ResolvedItemStack is { } resolved) + { + ItemStack stack = resolved.Clone(); + stack.StackSize = ingredient.Quantity; + if (ingredient.SatisfiesAsIngredient(stack)) + { + return stack; + } + } + + IEnumerable candidates; + if (ingredient.Code is { } code) + { + candidates = world.SearchBlocks(code) + .Where(block => block?.Code is not null) + .Select(block => new ItemStack(block, ingredient.Quantity)) + .Concat(world.SearchItems(code) + .Where(item => item?.Code is not null) + .Select(item => new ItemStack(item, ingredient.Quantity))); + } + else + { + candidates = world.Blocks + .Where(block => block?.Code is not null) + .Select(block => new ItemStack(block, ingredient.Quantity)) + .Concat(world.Items + .Where(item => item?.Code is not null) + .Select(item => new ItemStack(item, ingredient.Quantity))); + } + + return candidates.FirstOrDefault(candidate => ingredient.SatisfiesAsIngredient(candidate)); + } + + private static string FirepitIgnitionState(BlockEntity firepit, float secondsIgniting) + { + MethodInfo getIgnitableState = firepit.GetType().GetMethod( + "GetIgnitableState", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + ?? throw new MissingMethodException(firepit.GetType().FullName, "GetIgnitableState"); + return getIgnitableState.Invoke(firepit, [secondsIgniting])?.ToString() + ?? throw new InvalidOperationException("The firepit returned no ignition state."); + } + + private string TorchIgnitionState(BlockPos position, float secondsIgniting) => ServerHost!.RunOnGameThread(() => + { + ICoreServerAPI api = (ICoreServerAPI)ServerHost.Server.Api; + Block torch = api.World.BlockAccessor.GetBlock(position); + MethodInfo onTryIgniteBlock = torch.GetType().GetMethod( + "OnTryIgniteBlock", + BindingFlags.Instance | BindingFlags.Public) + ?? throw new MissingMethodException(torch.GetType().FullName, "OnTryIgniteBlock"); + EntityAgent player = (EntityAgent)(ServerHost.Server.GetClientByPlayername(PlayerName)?.Player.Entity + ?? throw new InvalidOperationException("The loopback player has no server entity.")); + return onTryIgniteBlock.Invoke(torch, [player, position, secondsIgniting])?.ToString() + ?? throw new InvalidOperationException("The vanilla torch returned no ignition state."); + }); + + private async Task PlaceExtinctTorchAsync() + { + BlockPos position = PlayerBlockPosition(2).UpCopy(); + ServerHost!.RunOnGameThread(() => + { + ICoreServerAPI api = (ICoreServerAPI)ServerHost.Server.Api; + Block block = api.World.GetBlock(new AssetLocation("game:torch-basic-extinct-up")) + ?? throw new InvalidOperationException("The vanilla extinct upright torch block is unavailable."); + api.World.BlockAccessor.SetBlock(block.BlockId, position); + }); + + bool synced = await StepUntilAsync( + () => ClientBlockCode(position).Equals("game:torch-basic-extinct-up", StringComparison.OrdinalIgnoreCase), + maxFrames: 120); + Assert.True(synced, "The server's extinct vanilla torch did not reach the client."); + return position; + } + + private async Task PlaceTallGrassNearPlayerAsync() + { + BlockPos position = PlayerBlockPosition(3).UpCopy(); + ServerHost!.RunOnGameThread(() => + { + ICoreServerAPI api = (ICoreServerAPI)ServerHost.Server.Api; + Block grass = api.World.GetBlock(new AssetLocation("game:tallgrass-medium-free")) + ?? throw new InvalidOperationException("The vanilla medium tall grass block is unavailable."); + api.World.BlockAccessor.SetBlock(grass.BlockId, position); + }); + + bool synced = await StepUntilAsync( + () => Client!.RunOnClientThread(() => + Client.Client.World.BlockAccessor.GetBlock(position).Code.Path == "tallgrass-medium-free"), + maxFrames: 600); + Assert.True(synced, "The tall grass fixture did not reach the client."); + return position; + } + + private async Task WaitForActiveItemAsync(string expectedCode) + { + bool synced = await StepUntilAsync( + () => Client!.RunOnClientThread(() => + string.Equals( + Client.Client.player?.InventoryManager?.ActiveHotbarSlot?.Itemstack?.Collectible.Code.ToString(), + expectedCode, + StringComparison.OrdinalIgnoreCase)), + maxFrames: 300); + + Assert.True(synced, + $"The freshly generated '{expectedCode}' stack did not reach the client's active hand."); + } + + private int ServerActiveItemCount() => ServerHost!.RunOnGameThread(() => + { + IServerPlayer player = ServerHost.Server.GetClientByPlayername(PlayerName)?.Player + ?? throw new InvalidOperationException("The loopback player is unavailable on the server."); + return player.InventoryManager.ActiveHotbarSlot.Itemstack?.StackSize ?? 0; + }); + + private int ClientActiveItemToolMode() => Client!.RunOnClientThread(() => + Client.Client.player?.InventoryManager?.ActiveHotbarSlot?.Itemstack?.Attributes.GetInt("toolMode", 0) ?? -1); + + private int ServerActiveItemToolMode() => ServerHost!.RunOnGameThread(() => + { + IServerPlayer player = ServerHost.Server.GetClientByPlayername(PlayerName)?.Player + ?? throw new InvalidOperationException("The loopback player is unavailable on the server."); + return player.InventoryManager.ActiveHotbarSlot.Itemstack?.Attributes.GetInt("toolMode", 0) ?? -1; + }); + + private void DamageServerPlayer(float amount) => ServerHost!.RunOnGameThread(() => + { + IServerPlayer player = ServerHost.Server.GetClientByPlayername(PlayerName)?.Player + ?? throw new InvalidOperationException("The loopback player is unavailable on the server."); + EntityPlayer entity = player.Entity + ?? throw new InvalidOperationException("The loopback player has no server entity."); + entity.ReceiveDamage(new DamageSource + { + Source = EnumDamageSource.Internal, + Type = EnumDamageType.Gravity, + IgnoreInvFrames = true, + }, amount); + }); + + private async Task EnsurePlayerOnGroundAsync() + { + TaskCompletionSource teleported = new(TaskCreationOptions.RunContinuationsAsynchronously); + ServerHost!.RunOnGameThread(() => + { + ICoreServerAPI api = (ICoreServerAPI)ServerHost!.Server.Api; + IServerPlayer player = ServerHost.Server.GetClientByPlayername(PlayerName)?.Player + ?? throw new InvalidOperationException("The loopback player is unavailable on the server."); + EntityPlayer entity = player.Entity + ?? throw new InvalidOperationException("The loopback player has no server entity."); + var spawn = ServerHost.Server.DefaultSpawnPosition; + int x = (int)Math.Floor(spawn.X); + int z = (int)Math.Floor(spawn.Z); + int floorY = api.World.BlockAccessor.GetTerrainMapheightAt(new BlockPos(x, 0, z)); + Block floor = api.World.GetBlock(new AssetLocation("game:soil-medium-none")) + ?? throw new InvalidOperationException("The vanilla medium soil block is unavailable."); + + for (int dx = -2; dx <= 2; dx++) + { + for (int dz = -2; dz <= 2; dz++) + { + api.World.BlockAccessor.SetBlock( + floor.BlockId, + new BlockPos(x + dx, floorY, z + dz)); + } + } + + player.WorldData.FreeMove = false; + player.WorldData.NoClip = false; + player.BroadcastPlayerData(); + entity.TeleportToDouble(x + 0.5, floorY + 1, z + 0.5, + () => teleported.TrySetResult()); + }); + + await StepUntilAsync(() => teleported.Task.IsCompleted, maxFrames: 600); + Assert.True(teleported.Task.IsCompleted, "The server did not complete the ground-fixture teleport."); + bool landed = await StepUntilAsync(ServerPlayerIsOnGround, maxFrames: 600); + Assert.True(landed, "The loopback player did not settle onto the prepared vanilla stone floor."); + await Session!.StepFramesAsync(10); + } + + private bool ServerPlayerIsOnGround() => ServerHost!.RunOnGameThread(() => + ServerHost.Server.GetClientByPlayername(PlayerName)?.Player?.Entity?.OnGround ?? false); + + private float ServerHealth() => ServerHost!.RunOnGameThread(() => + { + IServerPlayer player = ServerHost.Server.GetClientByPlayername(PlayerName)?.Player + ?? throw new InvalidOperationException("The loopback player is unavailable on the server."); + return player.Entity?.WatchedAttributes.GetTreeAttribute("health")?.GetFloat("currenthealth") + ?? throw new InvalidOperationException("The server player has no current health attribute."); + }); + + private float ClientHealth() => Client!.RunOnClientThread(() => + Client.Client.EntityPlayer?.WatchedAttributes.GetTreeAttribute("health")?.GetFloat("currenthealth") + ?? throw new InvalidOperationException("The client player has no current health attribute.")); + + private int ClientActiveItemCount() => Client!.RunOnClientThread(() => + Client.Client.player?.InventoryManager?.ActiveHotbarSlot?.Itemstack?.StackSize ?? 0); + + private string? ServerActiveItemCode() => ServerHost!.RunOnGameThread(() => + { + IServerPlayer player = ServerHost.Server.GetClientByPlayername(PlayerName)?.Player + ?? throw new InvalidOperationException("The loopback player is unavailable on the server."); + return player.InventoryManager.ActiveHotbarSlot.Itemstack?.Collectible.Code.ToString(); + }); + + private int ServerActiveItemDurability() => ServerHost!.RunOnGameThread(() => + { + IServerPlayer player = ServerHost.Server.GetClientByPlayername(PlayerName)?.Player + ?? throw new InvalidOperationException("The loopback player is unavailable on the server."); + ItemStack stack = player.InventoryManager.ActiveHotbarSlot.Itemstack + ?? throw new InvalidOperationException("The server player has no active item stack."); + return stack.Collectible.GetRemainingDurability(stack); + }); + + private async Task PlaceFueledFirepitAsync() + { + BlockPos position = PlayerBlockPosition(2).UpCopy(); + + ServerHost!.RunOnGameThread(() => + { + ICoreServerAPI api = (ICoreServerAPI)ServerHost.Server.Api; + Block block = api.World.GetBlock(new AssetLocation("game:firepit-extinct")) + ?? throw new InvalidOperationException("The vanilla extinct firepit block is unavailable."); + api.World.BlockAccessor.SetBlock(block.BlockId, position); + + BlockEntity firepit = api.World.BlockAccessor.GetBlockEntity(position) + ?? throw new InvalidOperationException("The vanilla firepit block entity was not created."); + PropertyInfo fuelSlotProperty = firepit.GetType().GetProperty( + "fuelSlot", + BindingFlags.Instance | BindingFlags.Public) + ?? throw new MissingMemberException(firepit.GetType().FullName, "fuelSlot"); + ItemSlot fuelSlot = (ItemSlot)fuelSlotProperty.GetValue(firepit)!; + Item firewood = api.World.GetItem(new AssetLocation("game:firewood")) + ?? throw new InvalidOperationException("The vanilla firewood item is unavailable."); + fuelSlot.Itemstack = new ItemStack(firewood, 4); + fuelSlot.MarkDirty(); + firepit.MarkDirty(redrawOnClient: true); + }); + + bool synced = await StepUntilAsync( + () => Client!.RunOnClientThread(() => + Client.Client.World.BlockAccessor.GetBlock(position).Code.Path == "firepit-extinct"), + maxFrames: 120); + Assert.True(synced, "The fueled firepit did not reach the client."); + return position; + } + + private BlockPos PlayerBlockPosition(int distance) => Client!.RunOnClientThread(() => + { + EntityPos position = Client.Client.EntityPlayer?.Pos + ?? throw new InvalidOperationException("The live client player has no position."); + return new BlockPos( + (int)Math.Floor(position.X) + distance, + (int)Math.Floor(position.Y) - 1, + (int)Math.Floor(position.Z)); + }); + + private string ClientBlockCode(BlockPos position) => Client!.RunOnClientThread(() => + Client.Client.World.BlockAccessor.GetBlock(position).Code.ToString()); + + private string ServerBlockCode(BlockPos position) => ServerHost!.RunOnGameThread(() => + ((ICoreServerAPI)ServerHost.Server.Api).World.BlockAccessor.GetBlock(position).Code.ToString()); +} diff --git a/tests/OptiTime.Pharos.Tests/MultiplayerOptimizationTests.cs b/tests/OptiTime.Pharos.Tests/MultiplayerOptimizationTests.cs index 85c0ad1..6236595 100644 --- a/tests/OptiTime.Pharos.Tests/MultiplayerOptimizationTests.cs +++ b/tests/OptiTime.Pharos.Tests/MultiplayerOptimizationTests.cs @@ -1,4 +1,6 @@ using Vintagestory.API.Common.Entities; +using Vintagestory.Common; +using Vintagestory.Server; using Zaldaryon.Pharos.Assertions; using Zaldaryon.Pharos.Network; @@ -58,69 +60,230 @@ public async Task ASecondPlayerJoinsAndTheClientSeesThem() } /// - /// A remote player must still move when interpolation is on. This is the - /// v1.5.13 regression: the animation LOD and the position interpolation - /// between them left entities visibly behind or teleporting, and a fishing - /// line detached as a result. Comparing where the remote player ends up - /// between the two states is the check that catches it. + /// Sends non-teleport position snapshots through the game's authoritative + /// server handler for the headless player and samples the remote entity on + /// the rendering client. Pharos 0.6 has no movement driver for its + /// headless player, so this calls the vanilla packet handler directly; the + /// handler's resulting UDP replication still travels through the real + /// embedded-server/client connection and reaches the Harmony target. The + /// Harmony patch is loaded at startup; changing its saved setting below + /// does not unload it, so this does not compare against vanilla + /// interpolation or model the reporter's multi-hour save/modlist. /// [ClientServerScenario(TimeoutMs = 900_000)] - public async Task RemotePlayerStaysInSyncWithInterpolationOnAndOff() + public async Task RemoteClientTracksServerPositionDuringRepeatedMoves() { await WarmUpAsync(); - ServerTestPlayer other = await CreateTestPlayerAsync("OtSync"); - await Session!.StepFramesAsync(120); - - var positions = new Dictionary(); + ServerTestPlayer other = await CreateTestPlayerAsync("OtTrack"); + Assert.True(await StepUntilAsync( + () => Client!.Client.World.GetEntityById(other.Entity!.EntityId) is not null, + maxFrames: 600), + "The second player's entity never reached the client."); + long remoteEntityId = other.Entity!.EntityId; - foreach (bool enabled in new[] { false, true }) + Vec3d origin = Player!.Position; + await other.TeleportTo(origin.X + 5, origin.Y, origin.Z); + await Session!.StepFramesAsync(30); + await WithOptimizationStateRestoredAsync("entityinterp", async () => { - await SetOptimizationAsync("entityinterp", enabled); - - for (int i = 0; i < 30; i++) + var samples = new List(); + double maxDistance = 0; + await RunCommandOkAsync($"{OptiTimeConfig.Command} interpdiag on"); + await RunCommandOkAsync($"{OptiTimeConfig.Command} interpdiag reset"); + + long droppedBefore = HarnessPatches.EnqueueShimDropped; + long deliveredBefore = HarnessPatches.EnqueueShimDelivered; + HarnessPatches.TrackEntityPositions(remoteEntityId); + Assert.True(HarnessPatches.IsEntityPositionReceivePatchInstalled, + "The test observer did not patch EntityBehaviorInterpolatePosition.OnReceivedServerPos."); + Client!.PacketRecorder.Clear(); + Client.PacketRecorder.Start(); + try { - await other.TeleportTo(20 + i * 2, 70, 20 + i * 2); - await Session!.StepFramesAsync(10); + foreach ((bool settingEnabled, double direction) in new[] { (false, 1.0), (true, -1.0) }) + { + await SetOptimizationAsync("entityinterp", settingEnabled); + + for (int i = 0; i < 12; i++) + { + int observedBefore = HarnessPatches.ReceivedPositionsFor(remoteEntityId).Count; + int shimPositionCountBefore = + HarnessPatches.PositionObservationsFor(remoteEntityId).Count; + int tcpPositionCountBefore = PharosLoopbackTests.ReadTcpPositionsFor( + Client.PacketRecorder.GetRecordedPackets(), remoteEntityId).Count; + int clientTickBefore = Client.RunOnClientThread(() => + { + Entity entity = Client.Client.World.GetEntityById(remoteEntityId) + ?? throw new InvalidOperationException("The remote player disappeared from the client world."); + return entity.Attributes.GetInt("tick"); + }); + var move = ServerHost!.RunOnGameThread(() => + { + Entity entity = other.Entity + ?? throw new InvalidOperationException("The remote test player has no server entity."); + Assert.False(entity.IsTeleport, + "A previous remote-player move left the game in teleport mode."); + + int serverTickBefore = entity.Attributes.GetInt("tick"); + EntityPos requestedPosition = entity.Pos.Copy(); + requestedPosition.X += direction * 0.5; + Packet_EntityPosition packet = ServerPackets.getEntityPositionPacket( + requestedPosition, + entity, + serverTickBefore + 1); + ServerHost.Server.ServerUdpNetwork.HandlePlayerPosition( + packet, + (ServerPlayer)(other.Player + ?? throw new InvalidOperationException("The remote test player has no server player."))); + + Packet_EntityPosition expectedSnapshot = ServerPackets.getEntityPositionPacket( + entity.Pos, + entity, + entity.Attributes.GetInt("tick")); + return ( + Requested: ToVec(requestedPosition), + Accepted: ToVec(entity.Pos), + ExpectedX: expectedSnapshot.X, + ExpectedY: expectedSnapshot.Y, + ExpectedZ: expectedSnapshot.Z, + ExpectedTick: expectedSnapshot.Tick, + ServerTickBefore: serverTickBefore); + }); + Assert.True((move.Accepted - move.Requested).Length() < 0.001, + $"The vanilla server rejected the requested move at settingEnabled={settingEnabled}, step={i}: " + + $"requested={move.Requested}, accepted={move.Accepted}."); + bool matchingSnapshotArrived = await StepUntilAsync(() => + { + IReadOnlyList observations = + HarnessPatches.ReceivedPositionsFor(remoteEntityId); + return observations.Skip(observedBefore).Any(observation => + observation.X == move.ExpectedX && + observation.Y == move.ExpectedY && + observation.Z == move.ExpectedZ); + }, maxFrames: 60); + if (!matchingSnapshotArrived) + { + IReadOnlyList shimPositions = + HarnessPatches.PositionObservationsFor(remoteEntityId); + IReadOnlyList receivedPositions = + HarnessPatches.ReceivedPositionsFor(remoteEntityId); + List tcpPositions = + PharosLoopbackTests.ReadTcpPositionsFor( + Client.PacketRecorder.GetRecordedPackets(), remoteEntityId); + int clientTickAfter = Client.RunOnClientThread(() => + { + Entity entity = Client.Client.World.GetEntityById(remoteEntityId) + ?? throw new InvalidOperationException("The remote player disappeared from the client world."); + return entity.Attributes.GetInt("tick"); + }); + Vec3d clientPositionAfter = Client.RunOnClientThread(() => + { + Entity entity = Client.Client.World.GetEntityById(remoteEntityId) + ?? throw new InvalidOperationException("The remote player disappeared from the client world."); + return ToVec(entity.Pos); + }); + int currentServerTick = ServerHost.RunOnGameThread(() => + other.Entity?.Attributes.GetInt("tick") ?? -1); + string Observed(IReadOnlyList values, System.Func format) => + values.Count == 0 ? "none" : string.Join("; ", values.Select(format).TakeLast(4)); + string failureDetails = + $"The accepted move did not reach the client's interpolation handler at " + + $"settingEnabled={settingEnabled}, step={i}; " + + $"requested={move.Requested}, accepted={move.Accepted}, " + + $"expectedPacket=({move.ExpectedX},{move.ExpectedY},{move.ExpectedZ},tick={move.ExpectedTick}), " + + $"serverTickBefore={move.ServerTickBefore}, serverTickAfter={currentServerTick}, " + + $"clientTickBefore={clientTickBefore}, clientTickAfter={clientTickAfter}, " + + $"clientPos={clientPositionAfter}, shimDelivered={HarnessPatches.EnqueueShimDelivered - deliveredBefore}, " + + $"shimDropped={HarnessPatches.EnqueueShimDropped - droppedBefore}, " + + $"shimPositions=[{Observed(shimPositions, p => $"({p.X},{p.Y},{p.Z},tick={p.Tick})") }], " + + $"tcpPositions=[{Observed(tcpPositions, p => $"({p.X},{p.Y},{p.Z},tick={p.Tick})") }], " + + $"callbacks=[{Observed(receivedPositions, p => $"({p.X},{p.Y},{p.Z},tick={p.Tick})") }]."; + Assert.True(matchingSnapshotArrived, failureDetails); + } + + IReadOnlyList shimPositionsForMove = + HarnessPatches.PositionObservationsFor(remoteEntityId).Skip(shimPositionCountBefore).ToArray(); + List tcpPositionsForMove = + PharosLoopbackTests.ReadTcpPositionsFor( + Client.PacketRecorder.GetRecordedPackets(), remoteEntityId) + .Skip(tcpPositionCountBefore) + .ToList(); + bool matchingWirePacketArrived = shimPositionsForMove.Concat(tcpPositionsForMove).Any(packet => + packet.X == move.ExpectedX && packet.Y == move.ExpectedY && + packet.Z == move.ExpectedZ && packet.Tick == move.ExpectedTick); + Assert.True(matchingWirePacketArrived, + $"The expected position packet did not reach the client network path at " + + $"settingEnabled={settingEnabled}, step={i}; " + + $"expected=({move.ExpectedX},{move.ExpectedY},{move.ExpectedZ},tick={move.ExpectedTick}), " + + $"shim=[{string.Join("; ", shimPositionsForMove.Select(p => $"({p.X},{p.Y},{p.Z},tick={p.Tick})"))}], " + + $"tcp=[{string.Join("; ", tcpPositionsForMove.Select(p => $"({p.X},{p.Y},{p.Z},tick={p.Tick})"))}]."); + await Session.StepFramesAsync(15); + + Vec3d server = ServerHost.RunOnGameThread(() => ToVec(other.Entity?.Pos)); + Vec3d client = Client!.RunOnClientThread(() => + { + Entity? entity = Client.Client.World.GetEntityById(remoteEntityId); + return ToVec(entity?.Pos); + }); + double distance = (client - server).Length(); + maxDistance = Math.Max(maxDistance, distance); + samples.Add($"settingEnabled={settingEnabled} step={i} target={move.Requested} " + + $"server={server} client={client} error={distance:F3} " + + $"packetTick={move.ExpectedTick} serverTickBefore={move.ServerTickBefore} " + + $"clientTickBefore={clientTickBefore}"); + } + } + + long interpolationSamples = PharosLoopbackTests.EntityInterpolationSamples(remoteEntityId); + int receivedSnapshots = HarnessPatches.ReceivedPositionsFor(remoteEntityId).Count; + List tcpPositionSnapshots = + PharosLoopbackTests.ReadTcpPositionsFor( + Client.PacketRecorder.GetRecordedPackets(), remoteEntityId); + IReadOnlyList shimPositionSnapshots = + HarnessPatches.PositionObservationsFor(remoteEntityId); + int uniquePositionSnapshots = tcpPositionSnapshots.Concat(shimPositionSnapshots).Distinct().Count(); + long droppedAfter = HarnessPatches.EnqueueShimDropped; + long deliveredAfter = HarnessPatches.EnqueueShimDelivered; + await WriteArtifactAsync(nameof(MultiplayerOptimizationTests), "remote-client-tracking.txt", + string.Join('\n', samples) + $"\nmaxError={maxDistance:F3}\n" + + $"receivedInterpolationSnapshots={receivedSnapshots}\n" + + $"entityInterpolationSamples={interpolationSamples}\n" + + $"enqueueShimDelivered={deliveredAfter - deliveredBefore}\n" + + $"enqueueShimDrops={droppedAfter - droppedBefore}\n" + + $"shimPositionSnapshots={shimPositionSnapshots.Count}\n" + + $"tcpPositionSnapshots={tcpPositionSnapshots.Count}\n" + + $"uniquePositionSnapshots={uniquePositionSnapshots}\n"); + + Assert.True(receivedSnapshots >= 24, + $"Expected a received position snapshot for each of the 24 accepted moves; got {receivedSnapshots}."); + Assert.True(uniquePositionSnapshots >= 24, + $"Expected at least 24 unique TCP/UDP position snapshots; got {uniquePositionSnapshots}."); + Assert.True(maxDistance < 0.75, + $"The remote client's position diverged by as much as {maxDistance:F3} blocks " + + "from the server after non-teleport position updates."); + Assert.True(interpolationSamples >= 23, + $"The remote player {remoteEntityId} reached the interpolation handler only " + + $"{interpolationSamples} time(s), below 23 expected updates after the initial baseline."); + Assert.Equal(droppedBefore, droppedAfter); } - - await Session!.StepFramesAsync(120); - - Vec3d server = ToVec(other.Entity?.Pos); - positions[enabled] = server; - } - - Vec3d without = positions[false]; - Vec3d with = positions[true]; - - double distance = (with - without).Length(); - - Console.WriteLine( - $"remote player after the run: interpolation off {without}, on {with}, " + - $"difference {distance:F3} blocks"); - - await WriteArtifactAsync(nameof(MultiplayerOptimizationTests), "sync.txt", - $"off={without}\non={with}\ndifference={distance}\n"); - - // The server's own entity position is authoritative and is not touched - // by a client-side mod, so this mostly documents that the client did - // not desync the server. The client-side check is the one below. - Assert.True(distance < 1.0, - $"The remote player's final position differs by {distance:F3} blocks between " + - "the two states. A client-side optimization must not move anything the " + - "server owns."); - - ClientServerAssert.PlayerPositionSynced(Session!); + finally + { + Client?.PacketRecorder.Stop(); + HarnessPatches.StopTrackingEntityPositions(remoteEntityId); + PharosLoopbackTests.DisableEntityInterpolationDiagnostics(); + } + }); } /// - /// Repulse agents is the module that stands in for physics that did not - /// happen. With a second player present, crowd pressure is real, and a - /// repulse-agent optimisation that skips the wrong tick shows up as two - /// players occupying the same block. + /// Confirms that the repulse-agent target remains live for a nearby remote + /// player and that OptiTime reports its ticks. The game controls whether + /// player-to-player collision is enabled, so this does not claim that two + /// players must move apart. /// [ClientServerScenario(TimeoutMs = 900_000)] - public async Task RepulseAgentsStillSeparatesTwoPlayers() + public async Task RepulseAgentModuleCountsNearbyRemotePlayerTicks() { await WarmUpAsync(); @@ -130,34 +293,36 @@ public async Task RepulseAgentsStillSeparatesTwoPlayers() // Drop the second player on top of the first, so the crowd case is // forced rather than hoped for. Vec3d origin = Player!.Position; - await other.TeleportTo(origin.X, origin.Y, origin.Z); + await other.TeleportTo(origin.X + 0.25, origin.Y, origin.Z); await Session!.StepFramesAsync(180); - await SetOptimizationAsync("repulseagents", enabled: true); - DiagCounters counters = await CaptureDiagAsync("repulse"); + await WithOptimizationStateRestoredAsync("repulseagents", async () => + { + await SetOptimizationAsync("repulseagents", enabled: true); + DiagCounters counters = await CaptureDiagAsync("repulse"); - Assert.True(counters.Present, "The 'repulse' diag module produced no dump."); - await WriteArtifactAsync(nameof(MultiplayerOptimizationTests), "repulse.txt", counters.Raw); + Assert.True(counters.Present, "The 'repulse' diag module produced no dump."); + await WriteArtifactAsync(nameof(MultiplayerOptimizationTests), "repulse.txt", counters.Raw); - double ticks = counters.Require(DiagCounter.Ticks, "repulse"); - Console.WriteLine(counters.Raw); + double ticks = counters.Require(DiagCounter.Ticks, "repulse"); + Console.WriteLine(counters.Raw); - Assert.True(ticks > 0, - "The repulse-agent optimization never ticked, even with two players standing " + - "in the same block. Its Harmony target did not fire at all."); + Assert.True(ticks > 0, + "The repulse-agent optimization never ticked, even with two players standing " + + "in the same block. Its Harmony target did not fire at all."); - ClientServerAssert.PlayerPositionSynced(Session!); + ClientServerAssert.PlayerPositionSynced(Session!); + }); } /// /// Entity shadow culling, at the settings a default install actually has. /// -/// The interesting outcome here is the finding, not the module. At the -/// vanilla default the mod declines to apply the patch - the far -/// cascade is shorter than its cull distance, so the transpiler could never -/// cull anything - and it says so in one log line. The status line keeps saying -/// ON. That is finding 4.4, and pinning it here means the test fails -/// when the mod is fixed. +/// At the vanilla default the mod declines to apply the patch because the +/// far cascade is shorter than its cull distance. The test accepts this safe +/// decline only when startup logs the reason and the status reports the option +/// as disabled. If a game preset raises shadow quality enough for the patch to +/// apply, the test instead requires entity diagnostics. /// /// Why this class cannot raise the setting instead. /// [ClientSetting] is applied by ClientServerScenarioBase after diff --git a/tests/OptiTime.Pharos.Tests/OptiTimeScenarioBase.cs b/tests/OptiTime.Pharos.Tests/OptiTimeScenarioBase.cs index bde5a6c..c3dfdde 100644 --- a/tests/OptiTime.Pharos.Tests/OptiTimeScenarioBase.cs +++ b/tests/OptiTime.Pharos.Tests/OptiTimeScenarioBase.cs @@ -745,6 +745,28 @@ protected async Task RestoreStatusAsync(IReadOnlyDictionary } } + /// + /// Runs a scenario after remembering one optimization's saved setting, then + /// restores that setting even if an assertion or interaction fails. + /// + protected async Task WithOptimizationStateRestoredAsync(string optimization, Func body) + { + OptionState before = await ReadOptionStateAsync(optimization) + ?? throw new InvalidOperationException($"'{optimization}' is absent from status output."); + + try + { + await body(); + } + finally + { + if (before.Conflict is null || before.Enabled) + { + await SetOptimizationAsync(optimization, before.Enabled); + } + } + } + /// /// Runs with one optimization in a given state and /// puts it back afterwards, whatever happens. diff --git a/tests/OptiTime.Pharos.Tests/PharosLoopbackTests.cs b/tests/OptiTime.Pharos.Tests/PharosLoopbackTests.cs index 5b7d3e8..4804ff7 100644 --- a/tests/OptiTime.Pharos.Tests/PharosLoopbackTests.cs +++ b/tests/OptiTime.Pharos.Tests/PharosLoopbackTests.cs @@ -50,83 +50,87 @@ public async Task TrackedEntityPositionPacketsReachInterpolationOverLoopback() $"The client did not receive the spawned chicken (entity {mover.EntityId}) before movement."); await Session.StepFramesAsync(60); - await SetOptimizationAsync("entityinterp", enabled: true); + await WithOptimizationStateRestoredAsync("entityinterp", async () => + { + await SetOptimizationAsync("entityinterp", enabled: true); - HarnessPatches.TrackEntityPositions(mover.EntityId); - Client!.PacketRecorder.Clear(); - Client.PacketRecorder.Start(); + HarnessPatches.TrackEntityPositions(mover.EntityId); + Client!.PacketRecorder.Clear(); + Client.PacketRecorder.Start(); - try - { - await RunCommandOkAsync($"{OptiTimeConfig.Command} interpdiag on"); - await RunCommandOkAsync($"{OptiTimeConfig.Command} interpdiag reset"); + try + { + await RunCommandOkAsync($"{OptiTimeConfig.Command} interpdiag on"); + await RunCommandOkAsync($"{OptiTimeConfig.Command} interpdiag reset"); + + for (int i = 0; i < 40; i++) + { + double x = origin.X + 4 + (i + 1) * 0.5; + ServerHost!.RunOnGameThread( + () => mover.TeleportToDouble(x, origin.Y, origin.Z + 4)); + await Session.StepFramesAsync(15); + } - for (int i = 0; i < 40; i++) + Client.PacketRecorder.Stop(); + + IReadOnlyList recorded = Client.PacketRecorder.GetRecordedPackets(); + List tcpPositions = + ReadTcpPositionsFor(recorded, mover.EntityId); + IReadOnlyList shimPositions = + HarnessPatches.PositionObservationsFor(mover.EntityId); + List positions = + tcpPositions.Concat(shimPositions).Distinct().ToList(); + + Console.WriteLine( + $"Entity {mover.EntityId}: {positions.Count} unique position snapshot(s), " + + $"{tcpPositions.Count} in recorded TCP envelopes, " + + $"{shimPositions.Count} delivered through the UDP receive shim; " + + $"shim delivered={HarnessPatches.EnqueueShimDelivered}, " + + $"dropped={HarnessPatches.EnqueueShimDropped}."); + + Assert.Equal(0, HarnessPatches.EnqueueShimDropped); + Assert.True(positions.Count > 0, + $"No UDP or TCP position snapshot for moving entity {mover.EntityId} reached the client."); + + int distinctPositions = positions + .Select(position => (position.X, position.Y, position.Z)) + .Distinct() + .Count(); + Assert.True(distinctPositions > 1, + $"The client received positions for entity {mover.EntityId}, but they never changed."); + + CommandOutput dump = await RunCommandAsync($"{OptiTimeConfig.Command} interpdiag dump"); + string text = Unescape(dump.Text); + await WriteArtifactAsync(nameof(PharosLoopbackTests), "interpdiag.txt", text); + Console.WriteLine(text); + + Assert.False(string.IsNullOrWhiteSpace(text), + "The interpolation diagnostic command printed nothing."); + + IReadOnlyList diagnosticLines = DiagReader.Lines( + ClientLogText(), "entityinterp"); + DiagCounters interpolation = DiagReader.Parse(string.Join(' ', diagnosticLines)); + Assert.True(interpolation.Require("samples", "entityinterp") > 0, + $"The interpolation handler recorded no repeated position samples. " + + $"Diagnostic output: {interpolation.Raw}"); + + long entitySamples = EntityInterpolationSamples(mover.EntityId); + Console.WriteLine( + $"Entity {mover.EntityId}: interpolation handler recorded {entitySamples} repeated samples."); + Assert.True(entitySamples > 0, + $"The interpolation handler recorded no repeated position samples for moving " + + $"entity {mover.EntityId}; diagnostic output: {interpolation.Raw}"); + } + finally { - double x = origin.X + 4 + (i + 1) * 0.5; - ServerHost!.RunOnGameThread( - () => mover.TeleportToDouble(x, origin.Y, origin.Z + 4)); - await Session.StepFramesAsync(15); + Client?.PacketRecorder.Stop(); + HarnessPatches.StopTrackingEntityPositions(mover.EntityId); + DisableEntityInterpolationDiagnostics(); } - - Client.PacketRecorder.Stop(); - - IReadOnlyList recorded = Client.PacketRecorder.GetRecordedPackets(); - List tcpPositions = - ReadTcpPositionsFor(recorded, mover.EntityId); - IReadOnlyList shimPositions = - HarnessPatches.PositionObservationsFor(mover.EntityId); - List positions = - tcpPositions.Concat(shimPositions).Distinct().ToList(); - - Console.WriteLine( - $"Entity {mover.EntityId}: {positions.Count} unique position snapshot(s), " + - $"{tcpPositions.Count} in recorded TCP envelopes, " + - $"{shimPositions.Count} delivered through the UDP receive shim; " + - $"shim delivered={HarnessPatches.Delivered}, dropped={HarnessPatches.Dropped}."); - - Assert.Equal(0, HarnessPatches.Dropped); - Assert.True(positions.Count > 0, - $"No UDP or TCP position snapshot for moving entity {mover.EntityId} reached the client."); - - int distinctPositions = positions - .Select(position => (position.X, position.Y, position.Z)) - .Distinct() - .Count(); - Assert.True(distinctPositions > 1, - $"The client received positions for entity {mover.EntityId}, but they never changed."); - - CommandOutput dump = await RunCommandAsync($"{OptiTimeConfig.Command} interpdiag dump"); - string text = Unescape(dump.Text); - await WriteArtifactAsync(nameof(PharosLoopbackTests), "interpdiag.txt", text); - Console.WriteLine(text); - - Assert.False(string.IsNullOrWhiteSpace(text), - "The interpolation diagnostic command printed nothing."); - - IReadOnlyList diagnosticLines = DiagReader.Lines( - ClientLogText(), "entityinterp"); - DiagCounters interpolation = DiagReader.Parse(string.Join(' ', diagnosticLines)); - Assert.True(interpolation.Require("samples", "entityinterp") > 0, - $"The interpolation handler recorded no repeated position samples. " + - $"Diagnostic output: {interpolation.Raw}"); - - long entitySamples = EntityInterpolationSamples(mover.EntityId); - Console.WriteLine( - $"Entity {mover.EntityId}: interpolation handler recorded {entitySamples} repeated samples."); - Assert.True(entitySamples > 0, - $"The interpolation handler recorded no repeated position samples for moving " + - $"entity {mover.EntityId}; diagnostic output: {interpolation.Raw}"); - } - finally - { - Client?.PacketRecorder.Stop(); - HarnessPatches.StopTrackingEntityPositions(mover.EntityId); - DisableEntityInterpolationDiagnostics(); - } + }); } - private static long EntityInterpolationSamples(long entityId) + internal static long EntityInterpolationSamples(long entityId) { Type? interpolationType = EntityInterpolationType(); FieldInfo? countsField = interpolationType?.GetField( @@ -138,7 +142,7 @@ private static long EntityInterpolationSamples(long entityId) return counts!.TryGetValue(entityId, out long samples) ? samples : 0; } - private static void DisableEntityInterpolationDiagnostics() + internal static void DisableEntityInterpolationDiagnostics() { MethodInfo? disable = EntityInterpolationType()?.GetMethod( "SetDiagnosticEnabled", BindingFlags.Public | BindingFlags.Static); @@ -152,7 +156,7 @@ private static void DisableEntityInterpolationDiagnostics() return modAssembly?.GetType("OptiTime.EntityInterpolationOptimization"); } - private static List ReadTcpPositionsFor( + internal static List ReadTcpPositionsFor( IReadOnlyList recorded, long entityId) {