Setup linux api build and some tests (#1542)

This commit is contained in:
2026-05-17 13:45:06 +02:00
parent 089a10a056
commit ca154827b2
17 changed files with 1103 additions and 2 deletions
+108
View File
@@ -0,0 +1,108 @@
-- Per-project Neovim config for the Daybreak repo.
-- Auto-sourced when nvim is started inside the repo, provided the user has
-- `vim.o.exrc = true` in their config (or runs `:set exrc`). Neovim 0.9+
-- prompts to trust this file on first load.
--
-- Commands added:
-- :DaybreakStageX86 [Config] build x86 components via wine and stage
-- them into Daybreak.Linux/bin/<Config>/...
-- /{Injector,Api}/ (default Config: Debug)
-- :DaybreakRunLinux run Daybreak.Linux via `dotnet run` in a
-- terminal split (honors launchSettings.json,
-- which easy-dot-net's launcher does not)
--
-- Note: a Linux/wine attach-debugger workflow was attempted (winedbg --gdb
-- stub *and* native Linux gdb -p attach), but both freeze GW reliably because
-- of ptrace/wineserver interactions that wine doesn't tolerate. For now,
-- prefer logging-based debugging via the API's console + log file in the
-- game directory (Daybreak.API<YYYYMMDD>.log next to Gw.exe).
local repo = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':p:h')
local scripts = repo .. '/Scripts'
-- ───── Progress notification with spinner ──────────────────────────────────
-- Uses nvim-notify / snacks.nvim's `id`/`replace` semantics to update a
-- single notification in place. Falls back to plain notify if the backend
-- doesn't support replace (the spinner just stops updating).
local SPINNER = { '', '', '', '', '', '', '', '' }
local function start_progress(title, message)
-- stable id so snacks.nvim/nvim-notify replace the same notification
local id = 'daybreak-' .. title:gsub('%s+', '-') .. '-' .. tostring(vim.uv.hrtime())
local state = { title = title, msg = message, frame = 1, done = false, id = id }
local function render()
local icon = state.done and '' or (SPINNER[state.frame] .. ' ')
local opts = {
title = state.title,
id = state.id,
timeout = state.done and 4000 or false,
hide_from_history = not state.done,
icon = state.done and '' or nil,
}
if state.notif then opts.replace = state.notif end
state.notif = vim.notify(icon .. state.msg, vim.log.levels.INFO, opts)
end
render()
state.timer = vim.uv.new_timer()
state.timer:start(80, 80, vim.schedule_wrap(function()
if state.done then return end
state.frame = (state.frame % #SPINNER) + 1
render()
end))
return {
update = function(msg) state.msg = msg; render() end,
finish = function(ok, msg)
state.done = true
if state.timer then state.timer:stop(); state.timer:close(); state.timer = nil end
state.msg = msg or state.msg
local final_opts = {
title = state.title,
id = state.id,
timeout = 4000,
icon = ok and '' or '',
}
if state.notif then final_opts.replace = state.notif end
vim.notify((ok and '' or '') .. state.msg,
ok and vim.log.levels.INFO or vim.log.levels.ERROR, final_opts)
end,
}
end
-- ───── :DaybreakStageX86 [Config] ─────────────────────────────────────────
vim.api.nvim_create_user_command('DaybreakStageX86', function(opts)
local config = opts.args ~= '' and opts.args or 'Debug'
local progress = start_progress('Daybreak StageX86', 'Building x86 components (' .. config .. ')…')
vim.fn.jobstart({ scripts .. '/StageX86ForDebug.sh', config }, {
cwd = repo,
on_exit = function(_, code)
vim.schedule(function()
if code == 0 then
progress.finish(true, 'Staged x86 (' .. config .. ')')
else
progress.finish(false, 'Stage failed (exit ' .. code ..
'). See /tmp/wine-injector-publish.log, /tmp/wine-api-publish.log')
end
end)
end,
stdout_buffered = false,
stderr_buffered = false,
})
end, {
nargs = '?',
complete = function() return { 'Debug', 'Release' } end,
desc = 'Build x86 components in wine and stage into Daybreak.Linux output',
})
-- ───── :DaybreakRunLinux ───────────────────────────────────────────────────
-- easy-dot-net launches the produced binary directly, bypassing launchSettings.json.
-- This runs Daybreak.Linux through `dotnet run`, which honors the environment
-- variables (GDK_BACKEND, WEBKIT_DISABLE_DMABUF_RENDERER, etc.) declared there.
-- Opens in the current window (no split) so it integrates with normal buffer
-- navigation; Ctrl-\ Ctrl-N to leave terminal mode, then your usual buffer
-- bindings work.
vim.api.nvim_create_user_command('DaybreakRunLinux', function()
vim.cmd('enew')
vim.fn.termopen({ 'dotnet', 'run', '--project', 'Daybreak.Linux/Daybreak.Linux.csproj' },
{ cwd = repo })
vim.cmd('startinsert')
end, { desc = 'Run Daybreak.Linux via dotnet run in the current window' })
@@ -1,7 +1,6 @@
using Daybreak.API.Extensions;
using Daybreak.API.Interop;
using Daybreak.API.Interop.GuildWars;
using Daybreak.API.Models;
using Daybreak.API.Services.Interop;
using Daybreak.Shared.Models.Api;
using System.Core.Extensions;
@@ -341,7 +340,7 @@ public sealed class CharacterSelectService(
await Task.Delay(100, cancellationToken);
}
catch(Exception e)
catch (Exception e)
{
scopedLogger.LogError(e, "Error while waiting for character select to be ready");
throw;
+7
View File
@@ -36,4 +36,11 @@
<ProjectReference Include="..\Daybreak.Core\Daybreak.Core.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="Fixtures\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Visible>true</Visible>
</None>
</ItemGroup>
</Project>
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,82 @@
using Daybreak.Services.BuildTemplates;
using Daybreak.Shared.Models.Builds;
using FluentAssertions;
namespace Daybreak.Tests.Services.BuildTemplates;
[TestClass]
public sealed class AttributePointCalculatorTests
{
// From PointsRequiredToIncreaseRankMapping: 1+2+3+4+5+6+7+9+11+13+16+20.
private const int PointsForRankTwelve = 97;
private readonly AttributePointCalculator calculator = new();
[TestMethod]
public void MaximumAttributePoints_IsTwoHundred()
{
this.calculator.MaximumAttributePoints.Should().Be(200);
}
[TestMethod]
[DataRow(0, 1)]
[DataRow(1, 2)]
[DataRow(7, 9)]
[DataRow(11, 20)]
public void GetPointsRequiredToIncreaseRank_KnownRank_ReturnsExpectedCost(int rank, int expected)
{
this.calculator.GetPointsRequiredToIncreaseRank(rank).Should().Be(expected);
}
[TestMethod]
public void GetPointsRequiredToIncreaseRank_AtMaxRank_ReturnsIntMaxValueSentinel()
{
this.calculator.GetPointsRequiredToIncreaseRank(12).Should().Be(int.MaxValue);
}
[TestMethod]
[DataRow(-1)]
[DataRow(13)]
public void GetPointsRequiredToIncreaseRank_OutOfRange_Throws(int rank)
{
var action = () => this.calculator.GetPointsRequiredToIncreaseRank(rank);
action.Should().Throw<ArgumentException>();
}
[TestMethod]
public void GetUsedPoints_EmptyBuild_ReturnsZero()
{
var build = new SingleBuildEntry { Attributes = [] };
this.calculator.GetUsedPoints(build).Should().Be(0);
}
[TestMethod]
public void GetUsedPoints_SingleAttributeAtRankTwelve_ReturnsCumulativeCost()
{
var build = new SingleBuildEntry { Attributes = [new AttributeEntry { Points = 12 }] };
this.calculator.GetUsedPoints(build).Should().Be(PointsForRankTwelve);
}
[TestMethod]
public void GetUsedPoints_MultipleAttributes_SumsIndependently()
{
// Rank 3 costs 1+2+3 = 6, rank 5 costs 1+2+3+4+5 = 15. Total = 21.
var build = new SingleBuildEntry
{
Attributes =
[
new AttributeEntry { Points = 3 },
new AttributeEntry { Points = 5 },
]
};
this.calculator.GetUsedPoints(build).Should().Be(21);
}
[TestMethod]
public void GetRemainingFreePoints_IsMaximumMinusUsed()
{
var build = new SingleBuildEntry { Attributes = [new AttributeEntry { Points = 12 }] };
this.calculator.GetRemainingFreePoints(build).Should().Be(200 - PointsForRankTwelve);
}
}
@@ -0,0 +1,81 @@
using Daybreak.Services.Experience;
using FluentAssertions;
namespace Daybreak.Tests.Services.Experience;
[TestClass]
public sealed class ExperienceCalculatorTests
{
private const uint Level20TotalXp = 182600;
private const uint PostCapPerLevel = 15000;
private readonly ExperienceCalculator calculator = new();
[TestMethod]
[DataRow(0u, 0u)]
[DataRow(1u, 1u)]
[DataRow(1999u, 1999u)]
[DataRow(2000u, 2000u)]
[DataRow(2001u, 1u)]
[DataRow(4600u, 2600u)]
[DataRow(4601u, 1u)]
[DataRow(154000u, 13400u)]
public void GetExperienceForCurrentLevel_BelowCap_ReturnsXpSincePreviousThreshold(uint totalXp, uint expected)
{
this.calculator.GetExperienceForCurrentLevel(totalXp).Should().Be(expected);
}
[TestMethod]
public void GetExperienceForCurrentLevel_AtCap_UsesModuloOfFixedRequirement()
{
this.calculator.GetExperienceForCurrentLevel(Level20TotalXp).Should().Be(0);
this.calculator.GetExperienceForCurrentLevel(Level20TotalXp + 1).Should().Be(1);
this.calculator.GetExperienceForCurrentLevel(Level20TotalXp + PostCapPerLevel).Should().Be(0);
this.calculator.GetExperienceForCurrentLevel(Level20TotalXp + PostCapPerLevel + 123).Should().Be(123);
}
[TestMethod]
[DataRow(0u, 2000u)]
[DataRow(1u, 1999u)]
[DataRow(1999u, 1u)]
[DataRow(2000u, 2600u)]
[DataRow(2001u, 2599u)]
[DataRow(168000u, 14600u)]
public void GetRemainingExperienceForNextLevel_BelowCap_ReturnsDistanceToNextThreshold(uint totalXp, uint expected)
{
this.calculator.GetRemainingExperienceForNextLevel(totalXp).Should().Be(expected);
}
[TestMethod]
public void GetRemainingExperienceForNextLevel_AtCap_UsesFixedRequirementMinusModulo()
{
this.calculator.GetRemainingExperienceForNextLevel(Level20TotalXp + 1).Should().Be(PostCapPerLevel - 1);
this.calculator.GetRemainingExperienceForNextLevel(Level20TotalXp + PostCapPerLevel - 1).Should().Be(1);
this.calculator.GetRemainingExperienceForNextLevel(Level20TotalXp + PostCapPerLevel).Should().Be(PostCapPerLevel);
}
[TestMethod]
public void GetTotalExperienceForNextLevel_IsCurrentPlusRemaining()
{
const uint totalXp = 12345u;
var remaining = this.calculator.GetRemainingExperienceForNextLevel(totalXp);
this.calculator.GetTotalExperienceForNextLevel(totalXp).Should().Be(totalXp + remaining);
}
[TestMethod]
[DataRow(0u, 2000u)]
[DataRow(1u, 2000u)]
[DataRow(2000u, 2600u)]
[DataRow(4600u, 3200u)]
public void GetNextExperienceThreshold_BelowCap_ReturnsSizeOfCurrentLevel(uint totalXp, uint expected)
{
this.calculator.GetNextExperienceThreshold(totalXp).Should().Be(expected);
}
[TestMethod]
public void GetNextExperienceThreshold_AtCap_ReturnsFixedRequirement()
{
this.calculator.GetNextExperienceThreshold(Level20TotalXp).Should().Be(PostCapPerLevel);
this.calculator.GetNextExperienceThreshold(Level20TotalXp + 100_000).Should().Be(PostCapPerLevel);
}
}
@@ -0,0 +1,114 @@
using Daybreak.Services.Guildwars.Utils;
using FluentAssertions;
namespace Daybreak.Tests.Services.Guildwars.Utils;
/// <summary>
/// Fixtures are real Gw.exe binaries committed under
/// <c>Daybreak.Tests/Fixtures/Gw/</c> and copied to the test output directory
/// at build time. Each binary is named <c>Gw.&lt;version&gt;.exe</c> where
/// <c>&lt;version&gt;</c> is the value the parser is expected to extract.
/// To point the tests at a different directory (e.g. a local cache outside the
/// repo), set <c>DAYBREAK_TEST_GW_FIXTURES</c>. Missing fixtures are reported
/// <c>Inconclusive</c> rather than failing. Future game updates add new
/// fixtures and a matching <c>DataRow</c>.
/// </summary>
[TestClass]
public sealed class GuildWarsExecutableParserTests
{
private const string LegacyFixture = "Gw.381488.exe";
private const int LegacyExpectedVersion = 381488;
private const string ReforgedFixture1 = "Gw.387585.exe";
private const int Reforged1ExpectedFileId = 387585;
private const string ReforgedFixture2 = "Gw.388118.exe";
private const int Reforged2ExpectedFileId = 388118;
private static string RequireFixture(string fileName)
{
// Default: <test-output>/Fixtures/Gw/<fileName> (committed in the repo and
// copied to bin/ via CopyToOutputDirectory). Override with
// DAYBREAK_TEST_GW_FIXTURES if you want to point at a different directory.
var dir = Environment.GetEnvironmentVariable("DAYBREAK_TEST_GW_FIXTURES")
?? Path.Combine(AppContext.BaseDirectory, "Fixtures", "Gw");
var path = Path.Combine(dir, fileName);
if (!File.Exists(path))
{
Assert.Inconclusive($"Fixture '{fileName}' not found under '{dir}'.");
}
return path;
}
[TestMethod]
public void TryParse_MissingFile_ReturnsNull()
{
GuildWarsExecutableParser.TryParse(Path.Combine(Path.GetTempPath(), $"definitely-not-here-{Guid.NewGuid():N}.exe"))
.Should().BeNull();
}
[TestMethod]
public void TryParse_NotAPeFile_ReturnsNull()
{
var path = Path.Combine(Path.GetTempPath(), $"daybreak-tests-{Guid.NewGuid():N}.bin");
File.WriteAllBytes(path, [0x00, 0x01, 0x02, 0x03, 0x04, 0x05]);
try
{
GuildWarsExecutableParser.TryParse(path).Should().BeNull();
}
finally
{
File.Delete(path);
}
}
[TestMethod]
[DataRow(ReforgedFixture1, Reforged1ExpectedFileId)]
[DataRow(ReforgedFixture2, Reforged2ExpectedFileId)]
public async Task GetFileId_ReforgedBuild_ReturnsExpectedFileId(string fileName, int expectedFileId)
{
var parser = GuildWarsExecutableParser.TryParse(RequireFixture(fileName));
parser.Should().NotBeNull();
var fileId = await parser!.GetFileId(CancellationToken.None);
fileId.Should().Be(expectedFileId);
}
[TestMethod]
[DataRow(ReforgedFixture1)]
[DataRow(ReforgedFixture2)]
public async Task GetVersionLegacy_ReforgedBuild_ThrowsBecauseLegacyPatternIsAbsent(string fileName)
{
var parser = GuildWarsExecutableParser.TryParse(RequireFixture(fileName));
parser.Should().NotBeNull();
var act = async () => await parser!.GetVersionLegacy(CancellationToken.None);
await act.Should().ThrowAsync<Exception>();
}
[TestMethod]
public async Task GetVersionLegacy_LegacyExe_ReturnsExpectedVersion()
{
var parser = GuildWarsExecutableParser.TryParse(RequireFixture(LegacyFixture));
parser.Should().NotBeNull();
var version = await parser!.GetVersionLegacy(CancellationToken.None);
version.Should().Be(LegacyExpectedVersion);
}
[TestMethod]
public async Task GetFileId_LegacyExe_ThrowsBecauseReforgedPatternIsAbsent()
{
var parser = GuildWarsExecutableParser.TryParse(RequireFixture(LegacyFixture));
parser.Should().NotBeNull();
var act = async () => await parser!.GetFileId(CancellationToken.None);
await act.Should().ThrowAsync<Exception>();
}
}
@@ -0,0 +1,42 @@
using Daybreak.Services.Initialization;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using NSubstitute;
namespace Daybreak.Tests.Services.Initialization;
[TestClass]
public sealed class MenuProducerTests
{
private readonly MenuProducer producer = new(Substitute.For<ILogger<MenuProducer>>());
[TestMethod]
public void CreateIfNotExistCategory_NewName_CreatesAndReturnsCategory()
{
var category = this.producer.CreateIfNotExistCategory("Tools");
category.Should().NotBeNull();
category.Name.Should().Be("Tools");
this.producer.categories.Should().ContainKey("Tools").WhoseValue.Should().BeSameAs(category);
}
[TestMethod]
public void CreateIfNotExistCategory_ExistingName_ReturnsSameInstanceWithoutDuplicating()
{
var first = this.producer.CreateIfNotExistCategory("Tools");
var second = this.producer.CreateIfNotExistCategory("Tools");
second.Should().BeSameAs(first);
this.producer.categories.Should().HaveCount(1);
}
[TestMethod]
public void CreateIfNotExistCategory_DifferentNames_KeepsBothCategories()
{
var tools = this.producer.CreateIfNotExistCategory("Tools");
var settings = this.producer.CreateIfNotExistCategory("Settings");
tools.Should().NotBeSameAs(settings);
this.producer.categories.Keys.Should().BeEquivalentTo("Tools", "Settings");
}
}
@@ -0,0 +1,169 @@
using Daybreak.Services.Logging;
using FluentAssertions;
using Serilog.Events;
using Serilog.Parsing;
namespace Daybreak.Tests.Services.Logging;
[TestClass]
public sealed class StructuredLogFormatterTests
{
private static LogEvent BuildEvent(
LogEventLevel level = LogEventLevel.Information,
string template = "hello",
Exception? exception = null,
IEnumerable<LogEventProperty>? properties = null,
DateTimeOffset? timestamp = null)
{
return new LogEvent(
timestamp ?? new DateTimeOffset(2024, 1, 2, 3, 4, 5, TimeSpan.Zero),
level,
exception,
new MessageTemplateParser().Parse(template),
properties ?? []);
}
[TestMethod]
public void Format_LiteralTextOnly_EmitsSingleTextToken()
{
var formatter = new StructuredLogFormatter("literal");
var tokens = formatter.Format(BuildEvent()).ToList();
tokens.Should().ContainSingle();
tokens[0].Type.Should().Be(StructuredLogFormatter.LogTokenType.Text);
tokens[0].Value.Should().Be("literal");
tokens[0].PropertyName.Should().BeNull();
}
[TestMethod]
public void Format_NewLineProperty_EmitsEnvironmentNewLine()
{
var formatter = new StructuredLogFormatter("{NewLine}");
var token = formatter.Format(BuildEvent()).Single();
token.Type.Should().Be(StructuredLogFormatter.LogTokenType.NewLine);
token.Value.Should().Be(Environment.NewLine);
token.PropertyName.Should().Be("NewLine");
}
[TestMethod]
public void Format_TimestampProperty_UsesDefaultFormatWhenNoneSpecified()
{
var formatter = new StructuredLogFormatter("{Timestamp}");
var token = formatter.Format(BuildEvent()).Single();
token.Type.Should().Be(StructuredLogFormatter.LogTokenType.Timestamp);
token.Value.Should().Be("2024-01-02 03:04:05");
}
[TestMethod]
public void Format_TimestampProperty_UsesExplicitFormat()
{
var formatter = new StructuredLogFormatter("{Timestamp:yyyy/MM/dd}");
var token = formatter.Format(BuildEvent()).Single();
token.Value.Should().Be("2024/01/02");
}
[TestMethod]
[DataRow(LogEventLevel.Verbose, "VRB")]
[DataRow(LogEventLevel.Debug, "DBG")]
[DataRow(LogEventLevel.Information, "INF")]
[DataRow(LogEventLevel.Warning, "WRN")]
[DataRow(LogEventLevel.Error, "ERR")]
[DataRow(LogEventLevel.Fatal, "FTL")]
public void Format_LevelProperty_U3Format_AbbreviatesToThreeChars(LogEventLevel level, string expected)
{
var formatter = new StructuredLogFormatter("{Level:u3}");
formatter.Format(BuildEvent(level)).Single().Value.Should().Be(expected);
}
[TestMethod]
[DataRow(LogEventLevel.Information, "INFO")]
[DataRow(LogEventLevel.Warning, "WARN")]
public void Format_LevelProperty_U4Format_AbbreviatesToFourChars(LogEventLevel level, string expected)
{
var formatter = new StructuredLogFormatter("{Level:u4}");
formatter.Format(BuildEvent(level)).Single().Value.Should().Be(expected);
}
[TestMethod]
public void Format_LevelProperty_NoFormat_ReturnsEnumName()
{
var formatter = new StructuredLogFormatter("{Level}");
formatter.Format(BuildEvent(LogEventLevel.Warning)).Single().Value.Should().Be("Warning");
}
[TestMethod]
public void Format_MessageProperty_RendersTemplate()
{
var formatter = new StructuredLogFormatter("{Message}");
var properties = new[] { new LogEventProperty("Name", new ScalarValue("world")) };
var token = formatter.Format(BuildEvent(template: "hello {Name}", properties: properties)).Single();
token.Type.Should().Be(StructuredLogFormatter.LogTokenType.Message);
token.Value.Should().Be("hello \"world\"");
}
[TestMethod]
public void Format_ExceptionProperty_OmittedWhenNoException()
{
var formatter = new StructuredLogFormatter("{Exception}");
formatter.Format(BuildEvent()).Should().BeEmpty();
}
[TestMethod]
public void Format_ExceptionProperty_EmitsExceptionToString()
{
var ex = new InvalidOperationException("boom");
var formatter = new StructuredLogFormatter("{Exception}");
var token = formatter.Format(BuildEvent(exception: ex)).Single();
token.Type.Should().Be(StructuredLogFormatter.LogTokenType.Exception);
token.Value.Should().Contain("boom").And.Contain(nameof(InvalidOperationException));
}
[TestMethod]
public void Format_EnrichedProperty_StripsSurroundingQuotes()
{
var formatter = new StructuredLogFormatter("{ThreadId}");
var properties = new[] { new LogEventProperty("ThreadId", new ScalarValue("42")) };
var token = formatter.Format(BuildEvent(properties: properties)).Single();
token.Type.Should().Be(StructuredLogFormatter.LogTokenType.Property);
token.Value.Should().Be("42");
token.PropertyName.Should().Be("ThreadId");
}
[TestMethod]
public void Format_UnknownProperty_EmitsNothing()
{
var formatter = new StructuredLogFormatter("{NotPresent}");
formatter.Format(BuildEvent()).Should().BeEmpty();
}
[TestMethod]
public void Format_CombinedTemplate_PreservesTokenOrder()
{
var formatter = new StructuredLogFormatter("[{Level:u3}] {Message}{NewLine}");
var properties = new[] { new LogEventProperty("Who", new ScalarValue("you")) };
var values = formatter.Format(BuildEvent(LogEventLevel.Error, template: "hi {Who}", properties: properties))
.Select(t => t.Value)
.ToList();
values.Should().Equal("[", "ERR", "] ", "hi \"you\"", Environment.NewLine);
}
}
@@ -0,0 +1,60 @@
using Daybreak.Services.TradeChat;
using Daybreak.Shared.Models.Guildwars;
using FluentAssertions;
namespace Daybreak.Tests.Services.TradeChat;
[TestClass]
public sealed class ItemHashServiceTests
{
private readonly ItemHashService service = new();
[TestMethod]
public void ComputeHash_NullModifiers_ReturnsNull()
{
this.service.ComputeHash(new TestItem { Modifiers = null }).Should().BeNull();
}
[TestMethod]
public void ComputeHash_EmptyModifiers_ReturnsNull()
{
this.service.ComputeHash(new TestItem { Modifiers = [] }).Should().BeNull();
}
[TestMethod]
public void ComputeHash_SingleModifier_ReturnsUppercaseHex()
{
var item = new TestItem { Modifiers = [(ItemModifier)0xABCu] };
this.service.ComputeHash(item).Should().Be("ABC");
}
[TestMethod]
public void ComputeHash_MultipleModifiers_ConcatenatesInOrder()
{
var item = new TestItem
{
Modifiers =
[
(ItemModifier)0x1u,
(ItemModifier)0xDEADBEEFu,
(ItemModifier)0x10u,
]
};
this.service.ComputeHash(item).Should().Be("1DEADBEEF10");
}
[TestMethod]
public void ComputeHash_IsOrderSensitive()
{
var first = new TestItem { Modifiers = [(ItemModifier)0x1u, (ItemModifier)0x2u] };
var second = new TestItem { Modifiers = [(ItemModifier)0x2u, (ItemModifier)0x1u] };
this.service.ComputeHash(first).Should().NotBe(this.service.ComputeHash(second));
}
private sealed class TestItem : ItemBase
{
}
}
@@ -0,0 +1,82 @@
using Daybreak.Utils;
using FluentAssertions;
namespace Daybreak.Tests.Utils;
[TestClass]
public sealed class PeVersionReaderTests
{
// Reuses the Gw.exe fixtures from GuildWarsExecutableParserTests
// (committed under Daybreak.Tests/Fixtures/Gw/).
private const string AnyFixture = "Gw.387585.exe";
private const string ExpectedProductName = "Guild Wars";
private const string ExpectedVersionString = "1, 0, 0, 1";
private static string RequireFixture(string fileName)
{
var dir = Environment.GetEnvironmentVariable("DAYBREAK_TEST_GW_FIXTURES")
?? Path.Combine(AppContext.BaseDirectory, "Fixtures", "Gw");
var path = Path.Combine(dir, fileName);
if (!File.Exists(path))
{
Assert.Inconclusive($"Fixture '{fileName}' not found under '{dir}'.");
}
return path;
}
[TestMethod]
public void GetProductVersion_MissingFile_ReturnsNull()
{
PeVersionReader.GetProductVersion(Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.exe"))
.Should().BeNull();
}
[TestMethod]
public void GetFileVersion_MissingFile_ReturnsNull()
{
PeVersionReader.GetFileVersion(Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.exe"))
.Should().BeNull();
}
[TestMethod]
public void GetProductName_MissingFile_ReturnsNull()
{
PeVersionReader.GetProductName(Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.exe"))
.Should().BeNull();
}
[TestMethod]
public void GetProductVersion_NotAPeFile_ReturnsNull()
{
var path = Path.Combine(Path.GetTempPath(), $"daybreak-tests-{Guid.NewGuid():N}.bin");
File.WriteAllBytes(path, [0x00, 0x01, 0x02, 0x03]);
try
{
PeVersionReader.GetProductVersion(path).Should().BeNull();
}
finally
{
File.Delete(path);
}
}
[TestMethod]
public void GetProductVersion_GwExe_ReturnsExpectedVersionString()
{
PeVersionReader.GetProductVersion(RequireFixture(AnyFixture)).Should().Be(ExpectedVersionString);
}
[TestMethod]
public void GetFileVersion_GwExe_ReturnsExpectedVersionString()
{
PeVersionReader.GetFileVersion(RequireFixture(AnyFixture)).Should().Be(ExpectedVersionString);
}
[TestMethod]
public void GetProductName_GwExe_ReturnsGuildWars()
{
PeVersionReader.GetProductName(RequireFixture(AnyFixture)).Should().Be(ExpectedProductName);
}
}
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
#
# Linux equivalent of Scripts/PublishWindowsX86.ps1.
#
# Publishes Daybreak.Injector (x86) and Daybreak.API (x86) for Windows by
# running the Windows .NET SDK under Wine, using lld-link as the platform
# linker and the MSVC CRT + Windows SDK x86 libraries extracted by xwin.
#
# Run Scripts/SetupWinePrefix.sh once before using this.
#
# Usage: Scripts/PublishLinuxWineX86.sh [Configuration]
# Env vars:
# WINEPREFIX — wine prefix to use (default: ~/.wine-daybreak)
set -euo pipefail
CONFIGURATION="${1:-Release}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$REPO_ROOT"
export WINEPREFIX="${WINEPREFIX:-$HOME/.wine-daybreak}"
DOTNET_EXE="$WINEPREFIX/drive_c/dotnet/dotnet.exe"
PROPS_FILE='C:\wine-aot.props'
if [[ ! -f "$DOTNET_EXE" ]]; then
echo "Wine prefix is not set up. Run Scripts/SetupWinePrefix.sh first." >&2
exit 1
fi
if [[ ! -f "$WINEPREFIX/drive_c/wine-aot.props" ]]; then
echo "Missing C:\\wine-aot.props in wine prefix. Re-run Scripts/SetupWinePrefix.sh." >&2
exit 1
fi
# Map repo into wine as R:\
ln -sfn "$REPO_ROOT" "$WINEPREFIX/dosdevices/r:"
# Environment for wine + dotnet
export WINEDLLOVERRIDES="mscoree=b"
export WINEDEBUG="${WINEDEBUG:--all}"
export DOTNET_NOLOGO=1
export DOTNET_CLI_TELEMETRY_OPTOUT=1
export DOTNET_GENERATE_ASPNET_CERTIFICATE=false
export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=true
INJECTOR_OUT='R:\Publish\Injector'
API_OUT='R:\Publish\Api'
# Clean previous outputs so MSBuild incremental can't get confused. The
# generator + shared projects are also wiped because their bin/Debug is
# shared with the Linux dotnet build (same netstandard2.0 / net10.0 TFM),
# which can leave the wine build half-incremental and CSC then fails to
# locate the generator dll mid-publish.
rm -rf "$REPO_ROOT/Publish/Injector" "$REPO_ROOT/Publish/Api"
for p in Daybreak.Injector Daybreak.API Daybreak.Generators Daybreak.Shared; do
rm -rf "$REPO_ROOT/$p/obj" "$REPO_ROOT/$p/bin"
done
# Kill any stale daemons from a previous wine session (these wedge stdout)
pkill_pat() {
pgrep -af "$1" 2>/dev/null | awk '{print $1}' | xargs -r kill -9 2>/dev/null || true
}
pkill_pat 'VBCSCompiler'
pkill_pat 'MSBuild.dll.*nodemode'
dotnet_publish() {
local proj="$1" out="$2" tag="$3"
local log="/tmp/wine-${tag}-publish.log"
echo "=== Publishing $proj$out (log: $log) ===" >&2
if ! wine "$DOTNET_EXE" publish "$proj" \
-c "$CONFIGURATION" -r win-x86 \
-o "$out" \
-nodeReuse:false -m:1 \
-p:UseSharedCompilation=false \
-p:EnableSourceLink=false \
-p:CustomAfterMicrosoftCommonProps="$PROPS_FILE" \
-v:m 2>&1 | tee "$log"; then
local rc=${PIPESTATUS[0]}
echo "!!! publish failed (exit $rc). Full log: $log" >&2
return "$rc"
fi
# MSBuild can exit 0 even when a build error was reported via Exec'd subprocess
if grep -qE '^[^[:space:]].*: error |error MSB[0-9]+|Build FAILED|build failed' "$log"; then
echo "!!! publish reported errors (see $log)" >&2
grep -E ': error |error MSB[0-9]+|Build FAILED' "$log" >&2 | head -20
return 1
fi
}
dotnet_publish 'R:\Daybreak.Injector\Daybreak.Injector.csproj' "$INJECTOR_OUT" injector
dotnet_publish 'R:\Daybreak.API\Daybreak.API.csproj' "$API_OUT" api
# wine spawns long-lived build-server processes that keep the parent shell's
# stdout pipe open even after publish returns. Reap them so this script exits.
pkill_pat 'VBCSCompiler'
pkill_pat 'MSBuild.dll.*nodemode'
wineserver -k 2>/dev/null || true
echo "=== Linux/Wine x86 publish complete ==="
echo "Injector: $REPO_ROOT/Publish/Injector"
echo "API: $REPO_ROOT/Publish/Api"
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env bash
#
# One-time setup for building the x86 Windows NativeAOT components
# (Daybreak.Injector, Daybreak.API) on Linux via Wine.
#
# What this installs into the wine prefix (default ~/.wine-daybreak):
# C:\dotnet — the .NET SDK for Windows (downloaded zip, no installer)
# C:\llvm — LLVM Windows binaries (we use lld-link.exe and llvm-lib.exe)
# C:\xwin — MSVC CRT + Windows SDK x86 lib files (extracted by xwin)
# C:\wine-aot.props — MSBuild properties consumed by PublishLinuxWineX86.sh
#
# Requires on the host: wine (>= 9), curl, unzip, tar, 7z, python3.
# `xwin` is downloaded automatically into the wine prefix if not on PATH.
#
# Re-running this script is safe: it skips components that are already present.
# Use --force to wipe and reinstall.
set -euo pipefail
# ─── configuration ────────────────────────────────────────────────────────────
DOTNET_SDK_CHANNEL="${DOTNET_SDK_CHANNEL:-10.0}"
LLVM_VERSION="${LLVM_VERSION:-22.1.5}"
XWIN_VERSION="${XWIN_VERSION:-0.9.0}"
WINEPREFIX_DEFAULT="$HOME/.wine-daybreak"
export WINEPREFIX="${WINEPREFIX:-$WINEPREFIX_DEFAULT}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$REPO_ROOT"
FORCE=0
for arg in "$@"; do
case "$arg" in
--force) FORCE=1 ;;
-h|--help)
sed -n '2,18p' "$0"
exit 0
;;
*) echo "Unknown argument: $arg" >&2; exit 2 ;;
esac
done
CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/daybreak-wine-setup"
mkdir -p "$CACHE_DIR"
log() { printf '\033[1;36m[setup]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[setup]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31m[setup]\033[0m %s\n' "$*" >&2; exit 1; }
for tool in wine curl unzip tar 7z python3; do
command -v "$tool" >/dev/null 2>&1 || die "Missing required tool: $tool"
done
if [[ $FORCE -eq 1 && -d "$WINEPREFIX" ]]; then
log "Removing existing prefix $WINEPREFIX (--force)"
rm -rf "$WINEPREFIX"
fi
# ─── 1. wine prefix ───────────────────────────────────────────────────────────
if [[ ! -f "$WINEPREFIX/system.reg" ]]; then
log "Initializing wine prefix at $WINEPREFIX"
mkdir -p "$WINEPREFIX"
WINEARCH=win64 wineboot --init >/dev/null 2>&1 || true
# wait for wineboot to settle
wineserver -w 2>/dev/null || true
else
log "Wine prefix already initialized at $WINEPREFIX"
fi
# ─── 2. .NET SDK ──────────────────────────────────────────────────────────────
DOTNET_DIR="$WINEPREFIX/drive_c/dotnet"
if [[ ! -f "$DOTNET_DIR/dotnet.exe" ]]; then
log "Resolving latest .NET $DOTNET_SDK_CHANNEL SDK"
SDK_INFO=$(curl -fsSL "https://builds.dotnet.microsoft.com/dotnet/release-metadata/${DOTNET_SDK_CHANNEL}/releases.json" | python3 -c "
import json,sys
d=json.load(sys.stdin)
v=d['latest-sdk']
for r in d['releases']:
if r['sdk']['version']==v:
for f in r['sdk']['files']:
if f['rid']=='win-x64' and f['name'].endswith('.zip'):
print(v); print(f['url']); sys.exit(0)
sys.exit('No win-x64 SDK zip found')
")
SDK_VER=$(echo "$SDK_INFO" | sed -n '1p')
SDK_URL=$(echo "$SDK_INFO" | sed -n '2p')
SDK_ZIP="$CACHE_DIR/dotnet-sdk-${SDK_VER}-win-x64.zip"
if [[ ! -f "$SDK_ZIP" ]]; then
log "Downloading .NET SDK $SDK_VER (~280 MB)"
curl -fSL --progress-bar -o "$SDK_ZIP.tmp" "$SDK_URL"
mv "$SDK_ZIP.tmp" "$SDK_ZIP"
else
log "Using cached SDK zip: $SDK_ZIP"
fi
mkdir -p "$DOTNET_DIR"
log "Extracting SDK into $DOTNET_DIR"
(cd "$DOTNET_DIR" && unzip -q "$SDK_ZIP")
else
log ".NET SDK already present in $DOTNET_DIR"
fi
# ─── 3. LLVM Windows binaries (lld-link.exe, llvm-lib.exe) ───────────────────
LLVM_DIR="$WINEPREFIX/drive_c/llvm"
if [[ ! -f "$LLVM_DIR/bin/lld-link.exe" ]]; then
LLVM_EXE="$CACHE_DIR/LLVM-${LLVM_VERSION}-win64.exe"
if [[ ! -f "$LLVM_EXE" ]]; then
log "Downloading LLVM ${LLVM_VERSION} for Windows (~435 MB)"
curl -fSL --progress-bar \
-o "$LLVM_EXE.tmp" \
"https://github.com/llvm/llvm-project/releases/download/llvmorg-${LLVM_VERSION}/LLVM-${LLVM_VERSION}-win64.exe"
mv "$LLVM_EXE.tmp" "$LLVM_EXE"
else
log "Using cached LLVM installer: $LLVM_EXE"
fi
mkdir -p "$LLVM_DIR"
log "Extracting LLVM into $LLVM_DIR"
(cd "$LLVM_DIR" && 7z x -y "$LLVM_EXE" >/dev/null)
else
log "LLVM already present in $LLVM_DIR"
fi
# ─── 4. xwin: MSVC CRT + Windows SDK x86 libs ─────────────────────────────────
XWIN_DIR="$WINEPREFIX/drive_c/xwin"
if [[ ! -d "$XWIN_DIR/sdk/lib/um/x86" ]]; then
XWIN_BIN="$(command -v xwin || true)"
if [[ -z "$XWIN_BIN" ]]; then
XWIN_BIN="$CACHE_DIR/xwin"
if [[ ! -x "$XWIN_BIN" ]]; then
log "Downloading xwin ${XWIN_VERSION}"
curl -fsSL "https://github.com/Jake-Shadle/xwin/releases/download/${XWIN_VERSION}/xwin-${XWIN_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz -C "$CACHE_DIR" --strip-components=1 "xwin-${XWIN_VERSION}-x86_64-unknown-linux-musl/xwin"
chmod +x "$XWIN_BIN"
fi
fi
log "Running xwin to fetch MSVC x86 libraries (~1 GB on disk; this takes a few minutes)"
mkdir -p "$XWIN_DIR"
"$XWIN_BIN" --accept-license --arch x86 \
--cache-dir "$CACHE_DIR/xwin-cache" \
splat --copy --include-debug-libs --output "$XWIN_DIR"
else
log "xwin output already present in $XWIN_DIR"
fi
# ─── 5. wine-aot.props ────────────────────────────────────────────────────────
PROPS_SRC="$SCRIPT_DIR/wine-aot.props"
PROPS_DST="$WINEPREFIX/drive_c/wine-aot.props"
[[ -f "$PROPS_SRC" ]] || die "Missing $PROPS_SRC (should be next to this script)"
cp "$PROPS_SRC" "$PROPS_DST"
log "Installed $PROPS_DST"
# ─── done ─────────────────────────────────────────────────────────────────────
log ""
log "Setup complete. Build the x86 components with:"
log " Scripts/PublishLinuxWineX86.sh"
log ""
log "Disk usage:"
du -sh "$DOTNET_DIR" "$LLVM_DIR" "$XWIN_DIR" 2>/dev/null | sed 's/^/ /'
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
#
# Build the x86 NativeAOT components via wine and copy them into the
# Daybreak.Linux output tree so `dotnet run --project Daybreak.Linux` (or
# easy-dot-net's "Run Daybreak.Linux") picks them up.
#
# Layout produced (matches VerifyNativeComponentsAction / DaybreakInjector):
# Daybreak.Linux/bin/<Config>/net10.0/linux-x64/Injector/Daybreak.Injector.exe
# Daybreak.Linux/bin/<Config>/net10.0/linux-x64/Api/Daybreak.API.dll
# Daybreak.Linux/bin/<Config>/net10.0/linux-x64/Api/gwca.dll
#
# Usage: Scripts/StageX86ForDebug.sh [Configuration]
# Configuration: Debug (default) or Release
#
# NativeAOT is built in Debug by default to match the Windows dev workflow
# (Scripts/PublishWindowsX86.ps1 stages Debug AOT into the Daybreak.Linux
# output tree). Debug AOT keeps PDBs and emits a console window on injection,
# which is great for live log output and gdb/lldb debugging.
#
# Override with STAGE_AOT_CONFIG=Release for a smaller, stripped binary.
set -euo pipefail
DAYBREAK_CONFIG="${1:-Debug}"
STAGE_AOT_CONFIG="${STAGE_AOT_CONFIG:-Debug}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$REPO_ROOT"
DEST_ROOT="$REPO_ROOT/Daybreak.Linux/bin/$DAYBREAK_CONFIG/net10.0/linux-x64"
mkdir -p "$DEST_ROOT/Injector" "$DEST_ROOT/Api"
echo "=== Building x86 components ($STAGE_AOT_CONFIG) via wine ==="
"$SCRIPT_DIR/PublishLinuxWineX86.sh" "$STAGE_AOT_CONFIG"
SRC_INJECTOR="$REPO_ROOT/Publish/Injector"
SRC_API="$REPO_ROOT/Publish/Api"
echo "=== Staging into $DEST_ROOT ==="
# rsync gives clean delete-extras + permission control; cp would leave orphans
if command -v rsync >/dev/null 2>&1; then
rsync -a --delete "$SRC_INJECTOR/" "$DEST_ROOT/Injector/"
rsync -a --delete "$SRC_API/" "$DEST_ROOT/Api/"
else
rm -rf "$DEST_ROOT/Injector"/* "$DEST_ROOT/Api"/*
cp -a "$SRC_INJECTOR/." "$DEST_ROOT/Injector/"
cp -a "$SRC_API/." "$DEST_ROOT/Api/"
fi
echo
echo "=== Restoring solution for Roslyn LSP ==="
# The wine publish leaves Daybreak.{API,Shared,Generators}/obj/ restored
# *only* for win-x86 (a Windows TFM), which Roslyn LSP can't reconcile when
# opening Daybreak.slnx — package references like ZLinq, ILogger<>, and
# SystemExtensions.* go unresolved in editor diagnostics. A plain Linux
# `dotnet restore` of the slnx rewrites each project's obj/ back to a
# multi-target shape Roslyn understands, without invalidating the staged
# binaries (those are physical files under Daybreak.Linux/bin/, not obj/).
dotnet restore "$REPO_ROOT/Daybreak.slnx" --nologo --verbosity quiet
echo
echo "=== Staged ==="
ls -la "$DEST_ROOT/Injector/" | sed 's/^/ /'
echo
ls -la "$DEST_ROOT/Api/" | sed 's/^/ /'
echo
echo "Now: dotnet run --project Daybreak.Linux -c $DAYBREAK_CONFIG"
+30
View File
@@ -0,0 +1,30 @@
<!--
MSBuild properties used by the wine-based x86 NativeAOT build.
Imported by Scripts/SetupWinePrefix.sh into the wine prefix, then passed to
`dotnet publish` via `-p:CustomAfterMicrosoftCommonProps=<this file>`.
Replaces the MSVC link.exe / lib.exe with LLVM equivalents (lld-link.exe,
llvm-lib.exe) and points the linker at the MSVC CRT + Windows SDK x86
libraries extracted by `xwin`. See Scripts/SetupWinePrefix.sh.
The override happens in a target with BeforeTargets="SetupOSSpecificProps;LinkNative"
because Microsoft.NETCore.Native.Windows.targets hard-codes <CppLinker>link</CppLinker>
in a top-level PropertyGroup, which would otherwise overwrite values set
via a regular imported props file.
-->
<Project>
<ItemGroup>
<AdditionalNativeLibraryDirectories Include="C:\xwin\crt\lib\x86" />
<AdditionalNativeLibraryDirectories Include="C:\xwin\sdk\lib\um\x86" />
<AdditionalNativeLibraryDirectories Include="C:\xwin\sdk\lib\ucrt\x86" />
</ItemGroup>
<Target Name="_WineAotOverrideLinker" BeforeTargets="SetupOSSpecificProps;LinkNative">
<PropertyGroup>
<IlcUseEnvironmentalTools>true</IlcUseEnvironmentalTools>
<CppLinker>C:\llvm\bin\lld-link.exe</CppLinker>
<CppLibCreator>C:\llvm\bin\llvm-lib.exe</CppLibCreator>
</PropertyGroup>
<Message Importance="high" Text="[wine-aot] CppLinker=$(CppLinker)" />
</Target>
</Project>