mirror of
https://github.com/gwdevhub/Daybreak.git
synced 2026-07-15 15:19:57 +00:00
Setup linux api build and some tests (#1542)
This commit is contained in:
@@ -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.Extensions;
|
||||||
using Daybreak.API.Interop;
|
using Daybreak.API.Interop;
|
||||||
using Daybreak.API.Interop.GuildWars;
|
using Daybreak.API.Interop.GuildWars;
|
||||||
using Daybreak.API.Models;
|
|
||||||
using Daybreak.API.Services.Interop;
|
using Daybreak.API.Services.Interop;
|
||||||
using Daybreak.Shared.Models.Api;
|
using Daybreak.Shared.Models.Api;
|
||||||
using System.Core.Extensions;
|
using System.Core.Extensions;
|
||||||
@@ -341,7 +340,7 @@ public sealed class CharacterSelectService(
|
|||||||
|
|
||||||
await Task.Delay(100, cancellationToken);
|
await Task.Delay(100, cancellationToken);
|
||||||
}
|
}
|
||||||
catch(Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
scopedLogger.LogError(e, "Error while waiting for character select to be ready");
|
scopedLogger.LogError(e, "Error while waiting for character select to be ready");
|
||||||
throw;
|
throw;
|
||||||
|
|||||||
@@ -36,4 +36,11 @@
|
|||||||
<ProjectReference Include="..\Daybreak.Core\Daybreak.Core.csproj" />
|
<ProjectReference Include="..\Daybreak.Core\Daybreak.Core.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="Fixtures\**\*">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
<Visible>true</Visible>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</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.<version>.exe</c> where
|
||||||
|
/// <c><version></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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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/^/ /'
|
||||||
@@ -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"
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user