mirror of
https://github.com/gwdevhub/Daybreak.git
synced 2026-09-16 21:35:20 +00:00
Compare commits
622
Commits
v0.9.8.118
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe4720c5e1 | ||
|
|
b57d2ad5d3 | ||
|
|
5faeae64d3 | ||
|
|
fdcf238d68 | ||
|
|
d9f7d05672 | ||
|
|
c61ee2ecd7 | ||
|
|
82512f9461 | ||
|
|
2dc13b7184 | ||
|
|
da691e3310 | ||
|
|
5e020f51d9 | ||
|
|
1f63e59b8a | ||
|
|
dcb9c29505 | ||
|
|
db83e5a954 | ||
|
|
c7c5506ee0 | ||
|
|
2988b6b38d | ||
|
|
debef9a36f | ||
|
|
839bc57b48 | ||
|
|
175c16bd00 | ||
|
|
ad356f431e | ||
|
|
ebe368e61e | ||
|
|
d800a630b5 | ||
|
|
239f91a65f | ||
|
|
aff7c24b6a | ||
|
|
5bf0af61ba | ||
|
|
03ed31e85b | ||
|
|
b21a4482bd | ||
|
|
2ad8ae79ce | ||
|
|
a4be747dd5 | ||
|
|
a6cd5d6f37 | ||
|
|
0e80fb7d86 | ||
|
|
0793f181bb | ||
|
|
a9ee0424f7 | ||
|
|
49f45b6b76 | ||
|
|
4216a03438 | ||
|
|
a9a8346a5b | ||
|
|
70a95b5e08 | ||
|
|
1e8c2e44e2 | ||
|
|
d553aadead | ||
|
|
fabb98f74e | ||
|
|
0a0ec5cefc | ||
|
|
0d31587855 | ||
|
|
f4a08e36dd | ||
|
|
c71dd6ed9c | ||
|
|
a61cff8dfd | ||
|
|
9d7f145c25 | ||
|
|
dc340189d6 | ||
|
|
5edb5ff780 | ||
|
|
890bb26c01 | ||
|
|
6c2574c722 | ||
|
|
8760f743ab | ||
|
|
97da183bd3 | ||
|
|
65439f25dc | ||
|
|
a755319fb1 | ||
|
|
7e72cb3d3f | ||
|
|
ca154827b2 | ||
|
|
089a10a056 | ||
|
|
65767d6ab5 | ||
|
|
5828263717 | ||
|
|
05f73cb306 | ||
|
|
0271e6b08c | ||
|
|
640f28584e | ||
|
|
dd89e3316e | ||
|
|
10fb70787b | ||
|
|
1393223826 | ||
|
|
b23907025d | ||
|
|
09aed18d9d | ||
|
|
b7d58f5e4c | ||
|
|
9888bc741f | ||
|
|
c1b2ed7548 | ||
|
|
c23a44a313 | ||
|
|
b217f183cb | ||
|
|
d24a0a49e8 | ||
|
|
cdd620a786 | ||
|
|
abbf87184b | ||
|
|
15fa4520aa | ||
|
|
bc963f486a | ||
|
|
66386a6b85 | ||
|
|
8d34d5d774 | ||
|
|
d5c8e7ad03 | ||
|
|
8eba2b92dd | ||
|
|
d9bd9d5395 | ||
|
|
a59f88d396 | ||
|
|
3799108f7e | ||
|
|
a7f0f31a94 | ||
|
|
6ffa774270 | ||
|
|
2124ae92b4 | ||
|
|
00312a6367 | ||
|
|
df0d32bc30 | ||
|
|
ecd19b0324 | ||
|
|
aa2e9905c1 | ||
|
|
384fdaf67c | ||
|
|
a48cd84e1a | ||
|
|
97fed608d2 | ||
|
|
68f241989a | ||
|
|
123d76fe0f | ||
|
|
9d495a35a6 | ||
|
|
5d9d59610a | ||
|
|
0bdfe69e78 | ||
|
|
8fd1584995 | ||
|
|
5da5eea085 | ||
|
|
fd9d8934f4 | ||
|
|
b9736f051a | ||
|
|
724e264b77 | ||
|
|
4974d5f9ed | ||
|
|
aa1da65538 | ||
|
|
e90ae48c40 | ||
|
|
ef513e1230 | ||
|
|
72fcf64589 | ||
|
|
53fc08bcb5 | ||
|
|
4077777cd9 | ||
|
|
77c2403a40 | ||
|
|
fa3c5e95a6 | ||
|
|
db012ae094 | ||
|
|
d124197e43 | ||
|
|
85593ea94b | ||
|
|
fe9178c66d | ||
|
|
e5e4e42e1e | ||
|
|
cc3236f4d6 | ||
|
|
6d598cdf95 | ||
|
|
974975eb78 | ||
|
|
4346ed758b | ||
|
|
a7320ac32c | ||
|
|
dce365b227 | ||
|
|
3ad32bee8f | ||
|
|
9e3fa738bb | ||
|
|
4a6eccca24 | ||
|
|
b1898c460c | ||
|
|
5c39a99b8f | ||
|
|
e873b0da6f | ||
|
|
9d9c533df7 | ||
|
|
9f15df9b6d | ||
|
|
5447633500 | ||
|
|
47369851f2 | ||
|
|
176365b4a4 | ||
|
|
ca526d352e | ||
|
|
c2d30ac60c | ||
|
|
3984b5a66d | ||
|
|
5be32e32ac | ||
|
|
5102ad0fe7 | ||
|
|
067da8a091 | ||
|
|
20d0bade79 | ||
|
|
a40c28711e | ||
|
|
1b878a73ae | ||
|
|
f4bbd3dbc6 | ||
|
|
cd46572c22 | ||
|
|
33dc001604 | ||
|
|
c9676d59d1 | ||
|
|
7f05738a63 | ||
|
|
f6687e0c72 | ||
|
|
1bf2fb07cc | ||
|
|
c826849bb7 | ||
|
|
82fdf5f18b | ||
|
|
5216cfcea3 | ||
|
|
a6bee38a19 | ||
|
|
2394059ca3 | ||
|
|
d4fb2e0545 | ||
|
|
cd52c20977 | ||
|
|
2070ae2689 | ||
|
|
3ca5117875 | ||
|
|
ea0a7a1edb | ||
|
|
824881c108 | ||
|
|
77e3d5ddf2 | ||
|
|
9626d7a42e | ||
|
|
75a6fb2b6c | ||
|
|
d887f2c901 | ||
|
|
b079be2445 | ||
|
|
f26ac481e9 | ||
|
|
6bfa52a0bf | ||
|
|
0ce4940342 | ||
|
|
969d24640f | ||
|
|
51916c0707 | ||
|
|
05910734fc | ||
|
|
55ccc6e367 | ||
|
|
6391b13cb4 | ||
|
|
32f08793ed | ||
|
|
d6237764d6 | ||
|
|
15ff789942 | ||
|
|
f4c4b5bac2 | ||
|
|
26b02ac94d | ||
|
|
1f8d4c126e | ||
|
|
8306e10ffe | ||
|
|
cc5fe38daa | ||
|
|
cc1bd1d77f | ||
|
|
437210858c | ||
|
|
47b7a480bf | ||
|
|
fe66d8e8b0 | ||
|
|
4b138e02d4 | ||
|
|
32bc16c695 | ||
|
|
0fae63f902 | ||
|
|
2447f7e517 | ||
|
|
f5483352f0 | ||
|
|
f081858d60 | ||
|
|
446f5cffd8 | ||
|
|
21b9cf1726 | ||
|
|
4f1398a8d1 | ||
|
|
072f00638c | ||
|
|
f7f22532bf | ||
|
|
a565017ad2 | ||
|
|
8b5f5c19e4 | ||
|
|
6d260a927d | ||
|
|
97eb3af350 | ||
|
|
ad70e80d4d | ||
|
|
dcffce5298 | ||
|
|
9ddcb6ba80 | ||
|
|
d77cefb699 | ||
|
|
bc8917692b | ||
|
|
02409f10fe | ||
|
|
f167ee538a | ||
|
|
c5dccb67dc | ||
|
|
2cb105acde | ||
|
|
fbc0c39a37 | ||
|
|
1da19b2e1d | ||
|
|
ef2076022e | ||
|
|
d6ad8429c8 | ||
|
|
7535be7134 | ||
|
|
33f8f73066 | ||
|
|
4c29a5617b | ||
|
|
2116285cac | ||
|
|
e85c286120 | ||
|
|
730722a0e0 | ||
|
|
8b90f13bd5 | ||
|
|
1360c86395 | ||
|
|
f6f0afc5d6 | ||
|
|
194fca5861 | ||
|
|
114ddacfcc | ||
|
|
dc5dfc8b07 | ||
|
|
976586649e | ||
|
|
4322b636ef | ||
|
|
cd8581f86a | ||
|
|
9417a1a486 | ||
|
|
f838d16543 | ||
|
|
0ec1c9c96f | ||
|
|
bab997b64a | ||
|
|
a0c6b7d956 | ||
|
|
fea568959b | ||
|
|
443582fda6 | ||
|
|
54e4c17af2 | ||
|
|
645c94f21b | ||
|
|
8e7b12b760 | ||
|
|
3e6ac4149f | ||
|
|
ebe045a962 | ||
|
|
5298ab1e83 | ||
|
|
64df882406 | ||
|
|
9dd39c0d93 | ||
|
|
2f5108fd3b | ||
|
|
9510fdc5de | ||
|
|
09781b5516 | ||
|
|
3044861652 | ||
|
|
8077d36e77 | ||
|
|
4ac9daa984 | ||
|
|
f1bfa66e79 | ||
|
|
a4485fc60f | ||
|
|
04c9d86ac4 | ||
|
|
0d30adf63b | ||
|
|
2949d47a29 | ||
|
|
f300f81d00 | ||
|
|
20ff35fb12 | ||
|
|
8c02b63051 | ||
|
|
3bae3cf64f | ||
|
|
948532a694 | ||
|
|
c43195d0bc | ||
|
|
7d48fde5ea | ||
|
|
69096795f7 | ||
|
|
7b2320e158 | ||
|
|
baa2628c6f | ||
|
|
62a6a018e1 | ||
|
|
77c15c9d63 | ||
|
|
0f338f5a53 | ||
|
|
a714fd7c68 | ||
|
|
1e933a4764 | ||
|
|
6cfa0d8976 | ||
|
|
b7d3bf67cc | ||
|
|
099d745c79 | ||
|
|
4ed61b3cae | ||
|
|
fab37a5ea0 | ||
|
|
1baa373f48 | ||
|
|
1b234c249d | ||
|
|
fc177a3007 | ||
|
|
de94b93f2d | ||
|
|
b211ab9409 | ||
|
|
440ccf4518 | ||
|
|
3f7a1cc310 | ||
|
|
d3cce2e24e | ||
|
|
8fa05c8854 | ||
|
|
d71ac6df06 | ||
|
|
cc3173ffe5 | ||
|
|
a92f566119 | ||
|
|
8df32b1009 | ||
|
|
26cb97caf4 | ||
|
|
66e2c5eb95 | ||
|
|
99444df04e | ||
|
|
b08d553c56 | ||
|
|
d7a48db83f | ||
|
|
b5d7719f48 | ||
|
|
6dfdc7376e | ||
|
|
7ee7be4d37 | ||
|
|
3554249d80 | ||
|
|
5a6fca6fa7 | ||
|
|
8d0a53a812 | ||
|
|
794afa6c2c | ||
|
|
7d78a89d12 | ||
|
|
dcb50e9da3 | ||
|
|
86e3579383 | ||
|
|
ea39346678 | ||
|
|
e4cdc4f939 | ||
|
|
5acac1ae7a | ||
|
|
8c0e734c71 | ||
|
|
a56eb20aa0 | ||
|
|
482854801c | ||
|
|
1a37b8b08f | ||
|
|
40d8280ba6 | ||
|
|
9533ef0607 | ||
|
|
ac963f60b5 | ||
|
|
5b6ea99f79 | ||
|
|
83ddafea62 | ||
|
|
ab1b604aec | ||
|
|
d445d0c5f2 | ||
|
|
f5216020b6 | ||
|
|
1b7654f70f | ||
|
|
06288ec2a2 | ||
|
|
670fba36df | ||
|
|
0d74a9c15e | ||
|
|
e0729d00ff | ||
|
|
0adce88894 | ||
|
|
77bf5e5359 | ||
|
|
5a92b916dd | ||
|
|
accfed2440 | ||
|
|
bdc45eb4b3 | ||
|
|
6916138351 | ||
|
|
741fb9059d | ||
|
|
6c9e47e58e | ||
|
|
0c3873b737 | ||
|
|
411f213d8b | ||
|
|
a55c2b78e9 | ||
|
|
da3d878d90 | ||
|
|
f08104a851 | ||
|
|
052bcaa29d | ||
|
|
51edeae93a | ||
|
|
6d12b2beaf | ||
|
|
7347de58af | ||
|
|
f1ee932f23 | ||
|
|
cdbc81e310 | ||
|
|
629c6ed14f | ||
|
|
6577db54bc | ||
|
|
926e9b7b48 | ||
|
|
c8433210e3 | ||
|
|
3274405c51 | ||
|
|
01bde80c39 | ||
|
|
3f810b8a94 | ||
|
|
5d7ba2598f | ||
|
|
670d6bcbd5 | ||
|
|
9690d649fa | ||
|
|
821ce69547 | ||
|
|
4f524ada67 | ||
|
|
0c2234f31b | ||
|
|
70085efd39 | ||
|
|
bfa2128868 | ||
|
|
9b0da9b13c | ||
|
|
721ee8e494 | ||
|
|
5458db891f | ||
|
|
9d9c15d0f6 | ||
|
|
4f49fc1cdf | ||
|
|
7efc7ba698 | ||
|
|
a79a6eae98 | ||
|
|
0801256f4c | ||
|
|
e0799dd100 | ||
|
|
24f4607899 | ||
|
|
7aee0dfc16 | ||
|
|
5fcb2314bc | ||
|
|
f218b89f6c | ||
|
|
cc607b37c9 | ||
|
|
f80ea8cf0c | ||
|
|
39f6707d23 | ||
|
|
eb11029ece | ||
|
|
4583b5b2d3 | ||
|
|
8c01377667 | ||
|
|
9a130d71dd | ||
|
|
018ed6c5b5 | ||
|
|
cb48248c82 | ||
|
|
e1284375e2 | ||
|
|
d38d620a84 | ||
|
|
ce810dafab | ||
|
|
aabc98e4fc | ||
|
|
7eb0b7cc4f | ||
|
|
f1eb829ac4 | ||
|
|
4b03a5a16f | ||
|
|
71c90100be | ||
|
|
ee512d130d | ||
|
|
03d0759e57 | ||
|
|
0db7628d66 | ||
|
|
d1d1ba6c08 | ||
|
|
d16a88a9bc | ||
|
|
a9d93f4162 | ||
|
|
632b5a55ac | ||
|
|
6424505631 | ||
|
|
dfc1d3ba0a | ||
|
|
5bee88016d | ||
|
|
5a37250680 | ||
|
|
1c4ccb9fa3 | ||
|
|
8403dadca7 | ||
|
|
4ac4cb0886 | ||
|
|
74f13d2447 | ||
|
|
bf53793729 | ||
|
|
e69dbf6791 | ||
|
|
a0eafa8a13 | ||
|
|
b0a236beb4 | ||
|
|
c10b81f3a1 | ||
|
|
6cd51d6584 | ||
|
|
a283117cc8 | ||
|
|
329e64232c | ||
|
|
feb3c9042e | ||
|
|
71f15f56a4 | ||
|
|
d37951b832 | ||
|
|
908a149df4 | ||
|
|
38cf140dfa | ||
|
|
d13e1a2d08 | ||
|
|
a0c7842b84 | ||
|
|
de13b68633 | ||
|
|
f289602068 | ||
|
|
15f4ee8979 | ||
|
|
d826dea457 | ||
|
|
b89a6666a3 | ||
|
|
1dc69fd0c2 | ||
|
|
5005b07f09 | ||
|
|
0b692165e0 | ||
|
|
ffee0fdf4c | ||
|
|
72b4cd5470 | ||
|
|
dc9be53ace | ||
|
|
3a8a6b0f7f | ||
|
|
7cdd0534f6 | ||
|
|
4b14ab9312 | ||
|
|
57e1ddb8da | ||
|
|
c7609c0f7e | ||
|
|
1eca8021d2 | ||
|
|
0077f35c4d | ||
|
|
28b0a0a138 | ||
|
|
cc7ce92013 | ||
|
|
5bd02e56ee | ||
|
|
8c7cd0a562 | ||
|
|
3ab3f72366 | ||
|
|
733883a6c2 | ||
|
|
d1ed8aeaf6 | ||
|
|
66ad44b8ce | ||
|
|
18e51ae408 | ||
|
|
06940dbf38 | ||
|
|
875b29f8ba | ||
|
|
a953c3a0d3 | ||
|
|
ce198fcfc9 | ||
|
|
3d84648af2 | ||
|
|
38bb43bba3 | ||
|
|
cc00709016 | ||
|
|
ee02b3b49a | ||
|
|
d1d6b7d757 | ||
|
|
4e1c0c4f50 | ||
|
|
2a7e2c90fc | ||
|
|
0dc957a99d | ||
|
|
f3f0aeb97e | ||
|
|
d6dbe6f2cd | ||
|
|
79da1801bc | ||
|
|
2b692b2d88 | ||
|
|
a7a98f7288 | ||
|
|
5d27d9c380 | ||
|
|
fb4185b541 | ||
|
|
d2d6e87e12 | ||
|
|
354cdda109 | ||
|
|
83429e264a | ||
|
|
ffdf374cdd | ||
|
|
aed34608ee | ||
|
|
18981e131d | ||
|
|
fe0a77d89d | ||
|
|
dbfd555f0d | ||
|
|
85d4ac3a29 | ||
|
|
dcb983d3de | ||
|
|
ba1e4348ad | ||
|
|
04bc7cffc6 | ||
|
|
01b74a936b | ||
|
|
654b6bc10f | ||
|
|
c9e6b9f626 | ||
|
|
554f1aea9b | ||
|
|
6fb817855c | ||
|
|
b3f5fadfe3 | ||
|
|
9275b7a7f8 | ||
|
|
2d06ff85b8 | ||
|
|
07d06313d9 | ||
|
|
546409130c | ||
|
|
82436872f8 | ||
|
|
ad6f76af0c | ||
|
|
8f0ad91a22 | ||
|
|
6a70c5b7cc | ||
|
|
5f5d67fdf2 | ||
|
|
150b41647a | ||
|
|
66e52e7324 | ||
|
|
23328240a9 | ||
|
|
3f06e9486d | ||
|
|
52a8e912e9 | ||
|
|
641aa8250e | ||
|
|
faa47f32c7 | ||
|
|
e3dac780df | ||
|
|
dfc0f12150 | ||
|
|
638fb38fa0 | ||
|
|
4d6a06ccdf | ||
|
|
3228604876 | ||
|
|
44879924d6 | ||
|
|
6b7478f6ae | ||
|
|
f54d95c1e7 | ||
|
|
5315a45a98 | ||
|
|
8575d970a3 | ||
|
|
5c611eca4f | ||
|
|
71aa37b965 | ||
|
|
0f1b1cc83d | ||
|
|
198c1e6eb3 | ||
|
|
e7c9111a1f | ||
|
|
1191e996db | ||
|
|
19de3c99f9 | ||
|
|
d857b6dc26 | ||
|
|
68b5b79035 | ||
|
|
318032d998 | ||
|
|
483860532f | ||
|
|
694dbe82a9 | ||
|
|
a936f24e32 | ||
|
|
a2ec87ced2 | ||
|
|
48955a7dd7 | ||
|
|
9d7c6c29b0 | ||
|
|
902d210dfd | ||
|
|
57867851a9 | ||
|
|
ca82cc92bf | ||
|
|
569ce1e656 | ||
|
|
a96c6c3f66 | ||
|
|
28c3b2f8c1 | ||
|
|
deeccd81b4 | ||
|
|
6a27245ae8 | ||
|
|
6f4063abb7 | ||
|
|
f307bcd665 | ||
|
|
02ca41ba6d | ||
|
|
a0ebc87b7c | ||
|
|
d21c55deb0 | ||
|
|
50a8131ec2 | ||
|
|
e70cfa3d99 | ||
|
|
4c0c45f1c7 | ||
|
|
a9e819a83a | ||
|
|
65830f9125 | ||
|
|
3d9106addf | ||
|
|
2863032d9a | ||
|
|
aada189b1b | ||
|
|
22ea4de66e | ||
|
|
9859617a74 | ||
|
|
0629c1929d | ||
|
|
6ec45396c3 | ||
|
|
b76a9b80fb | ||
|
|
f4bc58bbf7 | ||
|
|
09fdbb6f95 | ||
|
|
8ee7ed23e4 | ||
|
|
8d8a78cf88 | ||
|
|
f0652231cb | ||
|
|
d9aa760256 | ||
|
|
a65f375255 | ||
|
|
3ee58e2711 | ||
|
|
9b3b083aec | ||
|
|
ecb9b8f172 | ||
|
|
5765c27240 | ||
|
|
c53456112a | ||
|
|
ccea21886e | ||
|
|
37be8de5ac | ||
|
|
107e43a15f | ||
|
|
17b3ed34db | ||
|
|
cce9cd34af | ||
|
|
6a80f8ee5d | ||
|
|
edb7f0d0bd | ||
|
|
8e69375338 | ||
|
|
d24ae98fb0 | ||
|
|
111b3ea632 | ||
|
|
cc1e759972 | ||
|
|
ad0527ed73 | ||
|
|
8ffbb918ea | ||
|
|
c27a0dba65 | ||
|
|
ad8305c66d | ||
|
|
29fe12f094 | ||
|
|
9dbe4c5eaf | ||
|
|
1e9aca3807 | ||
|
|
895da23f7d | ||
|
|
f788a10879 | ||
|
|
9fe4be0467 | ||
|
|
b1a7d3a111 | ||
|
|
8b9fdaaa30 | ||
|
|
60d5947535 | ||
|
|
0ba0e64a2a | ||
|
|
e1dfd1f0d0 | ||
|
|
cd2a529381 | ||
|
|
6ba8dcac49 | ||
|
|
f2e76b5459 | ||
|
|
cfbf6ed021 | ||
|
|
a372eb5125 | ||
|
|
76b89ca0e8 | ||
|
|
2c1721d52c | ||
|
|
324a77a625 | ||
|
|
88e59b8ec9 | ||
|
|
df71a17d9f | ||
|
|
8451f53e19 | ||
|
|
e795af5c11 | ||
|
|
efa3c5175c | ||
|
|
32efe42a61 | ||
|
|
97d2d09cdc | ||
|
|
ed90d567a4 | ||
|
|
51d6fb0f42 | ||
|
|
c10525708c | ||
|
|
0a69d795aa | ||
|
|
3b1d6831a5 | ||
|
|
b4c1dbcddb | ||
|
|
db5d62d7c0 | ||
|
|
ed5ccf38c6 | ||
|
|
7671e0c04c | ||
|
|
f3b8221b29 | ||
|
|
8fdc9a67c0 | ||
|
|
bb2e739876 | ||
|
|
852cbff5f3 | ||
|
|
6e1c111876 | ||
|
|
9f60839e40 | ||
|
|
dd37db18b5 | ||
|
|
793d60f7b5 | ||
|
|
08674cd7a6 | ||
|
|
dca3e39a6d | ||
|
|
8a1033d4b6 |
@@ -0,0 +1,71 @@
|
||||
# Copilot instructions for Daybreak
|
||||
|
||||
Daybreak is a cross-platform Guild Wars launcher built on .NET 10 and
|
||||
Photino.Blazor. Read the
|
||||
[Architecture Overview](../README.md#architecture-overview) for the project
|
||||
layout and [CONTRIBUTING.md](../CONTRIBUTING.md) for the branching and release
|
||||
process.
|
||||
|
||||
## Workflow
|
||||
|
||||
- Branch off the current `release/[version]` branch, never off `master`.
|
||||
- One feature or fix per branch and per PR.
|
||||
- Never push to `master` and never edit the version in `Directory.Build.props`.
|
||||
Both are owned by the release workflows.
|
||||
- Title PRs `Short description (Closes #123)` so the linked issue gets labelled.
|
||||
|
||||
## Build and test
|
||||
|
||||
CI does not run on PRs targeting a release branch, so validate locally. Run the
|
||||
smallest command that covers the change:
|
||||
|
||||
```bash
|
||||
dotnet build Daybreak.Linux/Daybreak.Linux.csproj # or Daybreak.Windows
|
||||
dotnet test Daybreak.Tests/Daybreak.Tests.csproj --filter "FullyQualifiedName~YourTests"
|
||||
```
|
||||
|
||||
Keep the build warning-free.
|
||||
|
||||
## Where code goes
|
||||
|
||||
| Change | Project |
|
||||
| -------------------------------------------------- | ------------------------------------ |
|
||||
| Models, utilities, interfaces shared by everything | `Daybreak.Shared` |
|
||||
| Blazor views and services | `Daybreak.Core` |
|
||||
| Platform-specific implementations | `Daybreak.Windows`, `Daybreak.Linux` |
|
||||
|
||||
`Daybreak.Tests` only references `Daybreak.Core`. When adding platform code, put
|
||||
the logic worth testing in `Daybreak.Shared` and leave the platform project as a
|
||||
thin caller.
|
||||
|
||||
Add NuGet packages through `Directory.Packages.props`.
|
||||
|
||||
## C# conventions
|
||||
|
||||
`.editorconfig` is the source of truth. The projects are set up to treat
|
||||
warnings as errors.
|
||||
|
||||
## Verify before claiming
|
||||
|
||||
Prefer evidence over reasoning about behaviour, especially for the Wine and
|
||||
injection paths. Wine is scriptable, so reproduce the actual failure and confirm
|
||||
the fix against it rather than inferring what a Win32 call does.
|
||||
|
||||
## Writing style
|
||||
|
||||
Applies to docs, comments, PR descriptions and answers.
|
||||
|
||||
- Be short. Cut filler, recap and process narration.
|
||||
- No AI-isms and no inflated language.
|
||||
- Do not paste code into documentation. Link to the file so there is only one
|
||||
copy of it.
|
||||
- Explain the reason for a change, not a summary of the diff.
|
||||
|
||||
## Markdown
|
||||
|
||||
- No inline HTML. Use `![alt][ref]` with a reference definition instead of an
|
||||
`img` tag.
|
||||
- Start a mermaid fence with the diagram type, for example `flowchart LR`. YAML
|
||||
frontmatter inside the fence is rejected by some renderers.
|
||||
- Long URLs belong in reference-style link definitions at the bottom of the
|
||||
file.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Pre-configure SimplySign Desktop for unattended use:
|
||||
# - auto-show the login dialog on launch (so the TOTP keystrokes have a target)
|
||||
# - cache the smart-card PIN in the CSP, so signtool signs without a per-call
|
||||
# PIN prompt, and forget it again when the session disconnects.
|
||||
# Mirrors the settings used by the blinkdisk / docuscope CI signing setups.
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$RegistryPath = "HKCU:\Software\Certum\SimplySign"
|
||||
|
||||
$settings = [ordered]@{
|
||||
ShowLoginDialogOnStart = 1
|
||||
ShowLoginDialogOnAppRequest = 1
|
||||
RememberLastUserName = 0
|
||||
Autostart = 0
|
||||
UnregisterCertificatesOnDisconnect = 0
|
||||
RememberPINinCSP = 1
|
||||
ForgetPINinCSPonDisconnect = 1
|
||||
LangID = 9
|
||||
}
|
||||
|
||||
Write-Host "=== Configuring SimplySign Desktop registry ==="
|
||||
New-Item -Path $RegistryPath -Force | Out-Null
|
||||
foreach ($name in $settings.Keys) {
|
||||
Set-ItemProperty -Path $RegistryPath -Name $name -Value $settings[$name] -Type DWord
|
||||
Write-Host " $name = $($settings[$name])"
|
||||
}
|
||||
Write-Host "Done."
|
||||
@@ -0,0 +1,245 @@
|
||||
# Authenticate SimplySign Desktop non-interactively.
|
||||
#
|
||||
# Certum's SimplySign cloud has no headless login: the certificate only reaches
|
||||
# the Windows store after the GUI client authenticates. So we generate the
|
||||
# current TOTP from the otpauth:// secret (CERTUM_OTP_URI) and paste the
|
||||
# credentials into the login dialog. This needs an interactive desktop session,
|
||||
# which GitHub-hosted Windows runners provide.
|
||||
#
|
||||
# Based on https://www.devas.life/how-to-automate-signing-your-windows-app-with-certum/
|
||||
# and the refinements in blinkdisk's connect-simplysign.ps1.
|
||||
|
||||
param(
|
||||
[string]$OtpUri = $env:CERTUM_OTP_URI,
|
||||
[string]$UserId = $env:CERTUM_USERID,
|
||||
[string]$ExePath = $env:CERTUM_EXE_PATH
|
||||
)
|
||||
|
||||
if (-not $OtpUri) { Write-Host "ERROR: CERTUM_OTP_URI not provided"; exit 1 }
|
||||
if (-not $UserId) { Write-Host "ERROR: CERTUM_USERID not provided"; exit 1 }
|
||||
if (-not $ExePath) {
|
||||
$ExePath = "C:\Program Files\Certum\SimplySign Desktop\SimplySignDesktop.exe"
|
||||
}
|
||||
|
||||
Write-Host "=== SimplySign Desktop TOTP authentication ==="
|
||||
if (-not (Test-Path $ExePath)) {
|
||||
Write-Host "ERROR: SimplySign Desktop not found at $ExePath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Parse the otpauth:// URI (works on PowerShell 5.1 and 7+) ---------------
|
||||
$uri = [Uri]$OtpUri
|
||||
try {
|
||||
$q = [System.Web.HttpUtility]::ParseQueryString($uri.Query)
|
||||
} catch {
|
||||
$q = @{}
|
||||
foreach ($part in $uri.Query.TrimStart('?') -split '&') {
|
||||
$kv = $part -split '=', 2
|
||||
if ($kv.Count -eq 2) { $q[$kv[0]] = [Uri]::UnescapeDataString($kv[1]) }
|
||||
}
|
||||
}
|
||||
|
||||
$Base32 = $q['secret']
|
||||
$Digits = if ($q['digits']) { [int]$q['digits'] } else { 6 }
|
||||
$Period = if ($q['period']) { [int]$q['period'] } else { 30 }
|
||||
$Algorithm = if ($q['algorithm']) { $q['algorithm'].ToUpper() } else { 'SHA256' }
|
||||
|
||||
if (-not $Base32) { Write-Host "ERROR: otpauth URI has no 'secret'"; exit 1 }
|
||||
if ($Algorithm -notin @('SHA1', 'SHA256', 'SHA512')) {
|
||||
Write-Host "ERROR: unsupported TOTP algorithm: $Algorithm"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- TOTP generator (RFC 6238), inline C# so there are no dependencies --------
|
||||
Add-Type -Language CSharp @"
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
public static class Totp
|
||||
{
|
||||
private const string B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
|
||||
private static byte[] Base32Decode(string s)
|
||||
{
|
||||
s = s.TrimEnd('=').ToUpperInvariant();
|
||||
byte[] bytes = new byte[s.Length * 5 / 8];
|
||||
int bitBuffer = 0, bitsLeft = 0, idx = 0;
|
||||
foreach (char c in s)
|
||||
{
|
||||
int val = B32.IndexOf(c);
|
||||
if (val < 0) throw new ArgumentException("Invalid Base32 char: " + c);
|
||||
bitBuffer = (bitBuffer << 5) | val;
|
||||
bitsLeft += 5;
|
||||
if (bitsLeft >= 8) { bytes[idx++] = (byte)(bitBuffer >> (bitsLeft - 8)); bitsLeft -= 8; }
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static HMAC Hmac(string algorithm, byte[] key)
|
||||
{
|
||||
switch (algorithm.ToUpper())
|
||||
{
|
||||
case "SHA1": return new HMACSHA1(key);
|
||||
case "SHA256": return new HMACSHA256(key);
|
||||
case "SHA512": return new HMACSHA512(key);
|
||||
default: throw new ArgumentException("Unsupported algorithm: " + algorithm);
|
||||
}
|
||||
}
|
||||
|
||||
public static string Now(string secret, int digits, int period, string algorithm)
|
||||
{
|
||||
byte[] key = Base32Decode(secret);
|
||||
long counter = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / period;
|
||||
byte[] cnt = BitConverter.GetBytes(counter);
|
||||
if (BitConverter.IsLittleEndian) Array.Reverse(cnt);
|
||||
|
||||
byte[] hash;
|
||||
using (var h = Hmac(algorithm, key)) { hash = h.ComputeHash(cnt); }
|
||||
|
||||
int offset = hash[hash.Length - 1] & 0x0F;
|
||||
int binary =
|
||||
((hash[offset] & 0x7F) << 24) |
|
||||
((hash[offset + 1] & 0xFF) << 16) |
|
||||
((hash[offset + 2] & 0xFF) << 8) |
|
||||
(hash[offset + 3] & 0xFF);
|
||||
int otp = binary % (int)Math.Pow(10, digits);
|
||||
return otp.ToString(new string('0', digits));
|
||||
}
|
||||
}
|
||||
"@
|
||||
|
||||
function Find-UiByName($el, $walker, $name) {
|
||||
$c = $walker.GetFirstChild($el)
|
||||
while ($c) {
|
||||
if ($c.Current.Name -eq $name) { return $c }
|
||||
$r = Find-UiByName $c $walker $name
|
||||
if ($r) { return $r }
|
||||
$c = $walker.GetNextSibling($c)
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# An outdated SimplySign build pops a modal "New version found - download?" box
|
||||
# over the login form, which swallows the credential keystrokes. Decline it by
|
||||
# clicking "No" (Invoke / legacy default action / click the element's point).
|
||||
function Dismiss-UpdatePrompt {
|
||||
try { Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes -ErrorAction Stop } catch { return $false }
|
||||
if (-not ('Win32Mouse' -as [type])) {
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public static class Win32Mouse {
|
||||
[DllImport("user32.dll")] static extern bool SetCursorPos(int x, int y);
|
||||
[DllImport("user32.dll")] static extern void mouse_event(uint f, uint x, uint y, uint d, int e);
|
||||
public static void Click(int x, int y){ SetCursorPos(x,y); mouse_event(0x02,0,0,0,0); mouse_event(0x04,0,0,0,0); }
|
||||
}
|
||||
"@
|
||||
}
|
||||
$procIds = @((Get-Process -Name '*SimplySign*' -ErrorAction SilentlyContinue).Id)
|
||||
$walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker
|
||||
$top = $walker.GetFirstChild([System.Windows.Automation.AutomationElement]::RootElement)
|
||||
while ($top) {
|
||||
if ($procIds -contains $top.Current.ProcessId) {
|
||||
$no = Find-UiByName $top $walker "No"
|
||||
if ($no) {
|
||||
Write-Host "Update prompt detected; declining (clicking 'No')."
|
||||
try { ($no.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern)).Invoke(); return $true } catch {}
|
||||
try { ($no.GetCurrentPattern([System.Windows.Automation.LegacyIAccessiblePattern]::Pattern)).DoDefaultAction(); return $true } catch {}
|
||||
try { $pt = $no.GetClickablePoint(); [Win32Mouse]::Click([int]$pt.X, [int]$pt.Y); return $true } catch { Write-Host "Click 'No' failed: $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
$top = $walker.GetNextSibling($top)
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
# Start from a clean slate so a stale window/session can't swallow the keystrokes.
|
||||
if ($existing = Get-Process -Name "SimplySignDesktop" -ErrorAction Ignore) {
|
||||
Write-Host "Killing existing SimplySignDesktop process..."
|
||||
$existing | Stop-Process -Force
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
|
||||
Write-Host "Launching SimplySign Desktop..."
|
||||
$proc = Start-Process -FilePath $ExePath -PassThru
|
||||
Start-Sleep -Seconds 3
|
||||
|
||||
$wshell = New-Object -ComObject WScript.Shell
|
||||
|
||||
Write-Host "Focusing the login window..."
|
||||
$focused = $wshell.AppActivate($proc.Id)
|
||||
if (-not $focused) { $focused = $wshell.AppActivate('SimplySign Desktop') }
|
||||
for ($i = 0; (-not $focused) -and ($i -lt 10); $i++) {
|
||||
Start-Sleep -Milliseconds 500
|
||||
$focused = $wshell.AppActivate($proc.Id) -or $wshell.AppActivate('SimplySign Desktop')
|
||||
}
|
||||
if (-not $focused) {
|
||||
Write-Host "ERROR: could not bring SimplySign Desktop to the foreground"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Decline the "new version available" modal if it's covering the login form.
|
||||
if (Dismiss-UpdatePrompt) { Start-Sleep -Milliseconds 800 }
|
||||
|
||||
# Re-assert focus: dismissing the dialog can move the foreground window, and
|
||||
# keystrokes sent to the wrong window are silently dropped.
|
||||
$wshell.AppActivate($proc.Id) | Out-Null
|
||||
$wshell.AppActivate('SimplySign Desktop') | Out-Null
|
||||
Start-Sleep -Milliseconds 400
|
||||
|
||||
# Paste rather than type: SendKeys mangles characters like + ^ % ( ) and can
|
||||
# drop characters; pasting delivers the exact string. Falls back to typing if
|
||||
# the clipboard is unavailable.
|
||||
function Set-Field([string]$text) {
|
||||
try {
|
||||
Set-Clipboard -Value $text -ErrorAction Stop
|
||||
Start-Sleep -Milliseconds 150
|
||||
$wshell.SendKeys("^v")
|
||||
} catch {
|
||||
Write-Host "Clipboard unavailable ($($_.Exception.Message)); typing instead."
|
||||
$wshell.SendKeys($text)
|
||||
}
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
|
||||
Write-Host "Injecting credentials..."
|
||||
Set-Field $UserId
|
||||
$wshell.SendKeys("{TAB}")
|
||||
Start-Sleep -Milliseconds 200
|
||||
|
||||
# Generate the code right before sending it so it can't expire while we focus.
|
||||
$otp = [Totp]::Now($Base32, $Digits, $Period, $Algorithm)
|
||||
Set-Field $otp
|
||||
Start-Sleep -Milliseconds 200
|
||||
$wshell.SendKeys("{ENTER}")
|
||||
|
||||
# Don't leave the OTP / username sitting on the clipboard afterwards.
|
||||
try { Set-Clipboard -Value " " -ErrorAction Stop } catch {}
|
||||
|
||||
Write-Host "Waiting for authentication to settle..."
|
||||
Start-Sleep -Seconds 5
|
||||
|
||||
if (-not (Get-Process -Id $proc.Id -ErrorAction SilentlyContinue)) {
|
||||
Write-Host "ERROR: SimplySign Desktop exited - authentication failed."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Confirm the login actually mounted the signing certificate into the store.
|
||||
$thumb = if ($env:CERTUM_CERT_SHA1) { ($env:CERTUM_CERT_SHA1 -replace '\s', '').ToUpperInvariant() } else { $null }
|
||||
Write-Host "Verifying the signing certificate reached the store..."
|
||||
$deadline = (Get-Date).AddSeconds(60)
|
||||
$found = $false
|
||||
do {
|
||||
$certs = Get-ChildItem Cert:\CurrentUser\My, Cert:\LocalMachine\My -ErrorAction SilentlyContinue
|
||||
if ($thumb) { $found = [bool]($certs | Where-Object { $_.Thumbprint -eq $thumb }) }
|
||||
else { $found = [bool]($certs | Where-Object { $_.Subject -like '*Certum*' -or $_.Issuer -like '*Certum*' }) }
|
||||
if ($found) { break }
|
||||
Start-Sleep -Seconds 3
|
||||
} while ((Get-Date) -lt $deadline)
|
||||
|
||||
if ($found) {
|
||||
Write-Host "=== Authentication complete: signing certificate is available. ==="
|
||||
} else {
|
||||
Write-Host "ERROR: signing certificate did not appear after authentication."
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/bin/bash
|
||||
# Install Certum SimplySign Desktop on the (ephemeral) Windows runner.
|
||||
# SimplySign Desktop is what mounts the cloud signing certificate as a virtual
|
||||
# smart card into the Windows certificate store; signtool then selects it by
|
||||
# thumbprint. The version + checksum are pinned for reproducibility.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="9.3.4.72"
|
||||
URL="https://files.certum.eu/software/SimplySignDesktop/Windows/${VERSION}/SimplySignDesktop-${VERSION}-64-bit-en.msi"
|
||||
EXPECTED_SHA256="bd51ebbaaac20fc7d59ab7103b5ed532b7800586df4e31f6999d03a394f9c515"
|
||||
INSTALLER="SimplySignDesktop.msi"
|
||||
INSTALL_DIR="/c/Program Files/Certum/SimplySign Desktop"
|
||||
|
||||
echo "=== Installing Certum SimplySign Desktop ${VERSION} ==="
|
||||
|
||||
# Idempotent: a cached/pre-provisioned image may already have it.
|
||||
if [ -d "$INSTALL_DIR" ]; then
|
||||
echo "Already installed at: $INSTALL_DIR"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Downloading installer..."
|
||||
curl -L "$URL" -o "$INSTALLER" --fail --max-time 600
|
||||
|
||||
ACTUAL_SHA256=$(sha256sum "$INSTALLER" | awk '{print $1}')
|
||||
if [ "$EXPECTED_SHA256" != "$ACTUAL_SHA256" ]; then
|
||||
echo "ERROR: checksum mismatch"
|
||||
echo " expected: $EXPECTED_SHA256"
|
||||
echo " actual: $ACTUAL_SHA256"
|
||||
exit 1
|
||||
fi
|
||||
echo "Checksum verified."
|
||||
|
||||
echo "Running msiexec (silent)..."
|
||||
# Start-Process inherits this CWD, so the relative installer path resolves here.
|
||||
powershell -Command "Start-Process msiexec.exe -ArgumentList '/i','${INSTALLER}','/quiet','/norestart','/l*v','install.log','ALLUSERS=1','REBOOT=ReallySuppress' -Wait -NoNewWindow"
|
||||
|
||||
if [ ! -d "$INSTALL_DIR" ]; then
|
||||
echo "ERROR: installation directory not found after install"
|
||||
echo "Last 20 lines of install.log:"
|
||||
tail -20 install.log 2>/dev/null || echo "(no install.log)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "SimplySign Desktop installed."
|
||||
@@ -0,0 +1,66 @@
|
||||
# Sign the given files with the Certum cloud certificate (selected by SHA1
|
||||
# thumbprint from the store) and verify each signature. Assumes SimplySign
|
||||
# Desktop has already authenticated (connect-simplysign.ps1).
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string[]]$Files,
|
||||
[string]$CertSha1 = $env:CERTUM_CERT_SHA1,
|
||||
[string]$TimestampUrl = "http://time.certum.pl"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if (-not $CertSha1) { throw "CERTUM_CERT_SHA1 not provided." }
|
||||
$thumb = ($CertSha1 -replace '\s', '').ToUpperInvariant()
|
||||
|
||||
# Resolve the newest signtool.exe from the installed Windows SDK(s).
|
||||
$kitsBin = "C:\Program Files (x86)\Windows Kits\10\bin"
|
||||
$signtool = Get-ChildItem -Path $kitsBin -Recurse -Filter signtool.exe -File -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.FullName -like '*\x64\*' } |
|
||||
Sort-Object { [version]$_.Directory.Parent.Name } |
|
||||
Select-Object -Last 1 -ExpandProperty FullName
|
||||
if (-not $signtool) { throw "signtool.exe not found under $kitsBin" }
|
||||
Write-Host "Using signtool: $signtool"
|
||||
|
||||
# The SimplySign virtual smart card registers in the store a moment after the
|
||||
# desktop client authenticates; poll for our certificate before signing.
|
||||
Write-Host "Waiting for certificate $thumb to appear in the store..."
|
||||
$deadline = (Get-Date).AddSeconds(120)
|
||||
$cert = $null
|
||||
$elapsed = 0
|
||||
do {
|
||||
$cert = Get-ChildItem Cert:\CurrentUser\My, Cert:\LocalMachine\My -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Thumbprint -eq $thumb } | Select-Object -First 1
|
||||
if ($cert) { break }
|
||||
Start-Sleep -Seconds 3
|
||||
$elapsed += 3
|
||||
Write-Host " ...still waiting (${elapsed}s)"
|
||||
} while ((Get-Date) -lt $deadline)
|
||||
|
||||
if (-not $cert) {
|
||||
Write-Host "Certificate not found. CurrentUser\My + LocalMachine\My contents:"
|
||||
Get-ChildItem Cert:\CurrentUser\My, Cert:\LocalMachine\My -ErrorAction SilentlyContinue |
|
||||
Select-Object Thumbprint, Subject, HasPrivateKey | Format-Table -AutoSize | Out-String | Write-Host
|
||||
Write-Host "SimplySign processes:"
|
||||
Get-Process -Name '*SimplySign*' -ErrorAction SilentlyContinue |
|
||||
Select-Object Name, Id, Responding, MainWindowTitle | Format-Table -AutoSize | Out-String | Write-Host
|
||||
throw "Certificate $thumb not found in the store - SimplySign authentication likely failed (see dump above)."
|
||||
}
|
||||
Write-Host "Found signing certificate: $($cert.Subject)"
|
||||
|
||||
$failed = @()
|
||||
foreach ($file in $Files) {
|
||||
if (-not (Test-Path $file)) { throw "File to sign not found: $file" }
|
||||
Write-Host "=== Signing $file ==="
|
||||
& $signtool sign /sha1 $thumb /fd sha256 /td sha256 /tr $TimestampUrl /v $file
|
||||
if ($LASTEXITCODE -ne 0) { $failed += $file; continue }
|
||||
& $signtool verify /pa /v $file
|
||||
if ($LASTEXITCODE -ne 0) { $failed += $file }
|
||||
}
|
||||
|
||||
if ($failed.Count -gt 0) {
|
||||
throw "Signing/verification failed for: $($failed -join ', ')"
|
||||
}
|
||||
Write-Host "All binaries signed and verified."
|
||||
@@ -0,0 +1,26 @@
|
||||
# This action simulates a CD pipeline without the package publishing
|
||||
# to ensure that changes don't break the real run
|
||||
name: Daybreak Dry-Run CD Pipeline
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "Daybreak.Core/**"
|
||||
- "Daybreak.Installer/**"
|
||||
- "Daybreak.API/**"
|
||||
- "Daybreak.Shared/**"
|
||||
- "Daybreak.Windows/**"
|
||||
- "Daybreak.Linux/**"
|
||||
- ".github/scripts/**"
|
||||
- ".github/workflows/cd-template.yml"
|
||||
- ".github/workflows/cd-dry-run.yml"
|
||||
|
||||
jobs:
|
||||
cd:
|
||||
uses: ./.github/workflows/cd-template.yml
|
||||
with:
|
||||
publishRelease: false
|
||||
signBinaries: false
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,328 @@
|
||||
# Reusable CD pipeline with optional release publishing
|
||||
name: Daybreak CD Template
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
publishRelease:
|
||||
description: "Whether to create and publish the GitHub release"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
signBinaries:
|
||||
description: "Whether to code-sign Windows binaries"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
# ───────────────────────────────────────────────────────
|
||||
# Stage 1 — Build the x86 Windows-only components
|
||||
# (Injector + API) that both platforms need.
|
||||
# ───────────────────────────────────────────────────────
|
||||
build-x86:
|
||||
runs-on: windows-2025
|
||||
|
||||
outputs:
|
||||
version: ${{ steps.get_version.outputs.version }}
|
||||
|
||||
env:
|
||||
Configuration: Release
|
||||
Actions_Allow_Unsecure_Commands: true
|
||||
CERTUM_OTP_URI: ${{ secrets.CERTUM_OTP_URI }}
|
||||
CERTUM_USERID: ${{ secrets.CERTUM_USERID }}
|
||||
CERTUM_CERT_SHA1: ${{ secrets.CERTUM_CERT_SHA1 }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Get version
|
||||
id: get_version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = (Select-String -Path Directory.Build.props -Pattern '<Version>(.*)</Version>').Matches.Groups[1].Value
|
||||
echo "version=$version" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: "10.x"
|
||||
|
||||
- name: Publish x86 components
|
||||
run: .\Scripts\PublishWindowsX86.ps1
|
||||
shell: pwsh
|
||||
|
||||
# Certum SimplySign code signing. Binaries are signed only when explicitly
|
||||
# requested and the Certum secrets are provisioned.
|
||||
- name: Set up Certum SimplySign
|
||||
if: inputs.signBinaries && env.CERTUM_OTP_URI != ''
|
||||
shell: bash
|
||||
run: |
|
||||
chmod +x ./.github/scripts/install-simplysign.sh
|
||||
./.github/scripts/install-simplysign.sh
|
||||
powershell -ExecutionPolicy Bypass -File "./.github/scripts/configure-simplysign.ps1"
|
||||
|
||||
- name: Authenticate Certum SimplySign
|
||||
if: inputs.signBinaries && env.CERTUM_OTP_URI != ''
|
||||
shell: bash
|
||||
run: powershell -ExecutionPolicy Bypass -File "./.github/scripts/connect-simplysign.ps1"
|
||||
|
||||
- name: Sign x86 binaries
|
||||
if: inputs.signBinaries && env.CERTUM_OTP_URI != ''
|
||||
run: |
|
||||
./.github/scripts/sign-certum.ps1 -Files @(
|
||||
"Publish/Injector/Daybreak.Injector.exe",
|
||||
"Publish/Api/Daybreak.API.dll"
|
||||
)
|
||||
shell: pwsh
|
||||
|
||||
- name: Upload x86 artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: x86-components
|
||||
path: |
|
||||
Publish/Injector/
|
||||
Publish/Api/
|
||||
retention-days: 1
|
||||
|
||||
# ───────────────────────────────────────────────────────
|
||||
# Stage 2 — Platform builds (parallel)
|
||||
# ───────────────────────────────────────────────────────
|
||||
build-windows:
|
||||
needs: build-x86
|
||||
runs-on: windows-2025
|
||||
|
||||
env:
|
||||
Configuration: Release
|
||||
Actions_Allow_Unsecure_Commands: true
|
||||
CERTUM_OTP_URI: ${{ secrets.CERTUM_OTP_URI }}
|
||||
CERTUM_USERID: ${{ secrets.CERTUM_USERID }}
|
||||
CERTUM_CERT_SHA1: ${{ secrets.CERTUM_CERT_SHA1 }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: "10.x"
|
||||
|
||||
- name: Setup project secrets
|
||||
run: |
|
||||
dotnet user-secrets --project Daybreak.Core\Daybreak.Core.csproj set AadApplicationId "${{ secrets.AadApplicationId }}"
|
||||
dotnet user-secrets --project Daybreak.Core\Daybreak.Core.csproj set AadTenantId "${{ secrets.AadTenantId }}"
|
||||
dotnet user-secrets --project Daybreak.Core\Daybreak.Core.csproj set ApmServiceAccount "${{ secrets.ApmServiceAccount }}"
|
||||
dotnet user-secrets --project Daybreak.Core\Daybreak.Core.csproj set ApmServiceKey "${{ secrets.ApmServiceKey }}"
|
||||
dotnet user-secrets --project Daybreak.Core\Daybreak.Core.csproj set ApmUri "${{ secrets.ApmUri }}"
|
||||
|
||||
- name: Download x86 artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: x86-components
|
||||
path: Publish/
|
||||
|
||||
- name: Publish Windows x64
|
||||
run: .\Scripts\PublishWindows.ps1
|
||||
shell: pwsh
|
||||
|
||||
- name: Set up Certum SimplySign
|
||||
if: inputs.signBinaries && env.CERTUM_OTP_URI != ''
|
||||
shell: bash
|
||||
run: |
|
||||
chmod +x ./.github/scripts/install-simplysign.sh
|
||||
./.github/scripts/install-simplysign.sh
|
||||
powershell -ExecutionPolicy Bypass -File "./.github/scripts/configure-simplysign.ps1"
|
||||
|
||||
- name: Authenticate Certum SimplySign
|
||||
if: inputs.signBinaries && env.CERTUM_OTP_URI != ''
|
||||
shell: bash
|
||||
run: powershell -ExecutionPolicy Bypass -File "./.github/scripts/connect-simplysign.ps1"
|
||||
|
||||
- name: Sign Windows build
|
||||
if: inputs.signBinaries && env.CERTUM_OTP_URI != ''
|
||||
run: |
|
||||
./.github/scripts/sign-certum.ps1 -Files @(
|
||||
"Publish/Daybreak.exe",
|
||||
"Publish/Installer/Daybreak.Installer.exe"
|
||||
)
|
||||
shell: pwsh
|
||||
|
||||
- name: Upload Windows build
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: windows-build
|
||||
path: Publish/
|
||||
retention-days: 1
|
||||
|
||||
build-linux:
|
||||
needs: build-x86
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
Configuration: Release
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: "10.x"
|
||||
|
||||
- name: Setup project secrets
|
||||
run: |
|
||||
dotnet user-secrets --project Daybreak.Core/Daybreak.Core.csproj set AadApplicationId "${{ secrets.AadApplicationId }}"
|
||||
dotnet user-secrets --project Daybreak.Core/Daybreak.Core.csproj set AadTenantId "${{ secrets.AadTenantId }}"
|
||||
dotnet user-secrets --project Daybreak.Core/Daybreak.Core.csproj set ApmServiceAccount "${{ secrets.ApmServiceAccount }}"
|
||||
dotnet user-secrets --project Daybreak.Core/Daybreak.Core.csproj set ApmServiceKey "${{ secrets.ApmServiceKey }}"
|
||||
dotnet user-secrets --project Daybreak.Core/Daybreak.Core.csproj set ApmUri "${{ secrets.ApmUri }}"
|
||||
|
||||
- name: Download x86 artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: x86-components
|
||||
path: Publish/
|
||||
|
||||
- name: Publish Linux x64
|
||||
run: pwsh ./Scripts/PublishLinux.ps1
|
||||
shell: bash
|
||||
|
||||
- name: Set executable permissions
|
||||
run: |
|
||||
chmod +x Publish/Daybreak
|
||||
chmod +x Publish/Daybreak.Wayland.sh
|
||||
chmod +x Publish/Installer/Daybreak.Installer
|
||||
|
||||
- name: Upload Linux build
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: linux-build
|
||||
path: Publish/
|
||||
retention-days: 1
|
||||
|
||||
# ───────────────────────────────────────────────────────
|
||||
# Stage 3 — Package (always)
|
||||
# ───────────────────────────────────────────────────────
|
||||
package:
|
||||
needs: [build-x86, build-windows, build-linux]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
Actions_Allow_Unsecure_Commands: true
|
||||
Version: ${{ needs.build-x86.outputs.version }}
|
||||
|
||||
steps:
|
||||
# ── Windows package ──
|
||||
- name: Download Windows build
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: windows-build
|
||||
path: windows-publish/
|
||||
|
||||
- name: Clean and zip Windows build
|
||||
run: |
|
||||
cd windows-publish
|
||||
find . -name '*.pdb' -delete
|
||||
find . -name '*.dbg' -delete
|
||||
if [ -f Installer/Daybreak.Installer.exe ]; then
|
||||
mv Installer/Daybreak.Installer.exe Installer/Daybreak.Installer.Temp.exe
|
||||
fi
|
||||
cd ..
|
||||
cd windows-publish && zip -r ../daybreakv${{ env.Version }}.zip . && cd ..
|
||||
|
||||
# ── Linux package ──
|
||||
- name: Download Linux build
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: linux-build
|
||||
path: linux-publish/
|
||||
|
||||
- name: Clean and zip Linux build
|
||||
run: |
|
||||
cd linux-publish
|
||||
find . -name '*.pdb' -delete
|
||||
find . -name '*.dbg' -delete
|
||||
if [ -f Installer/Daybreak.Installer ]; then
|
||||
chmod +x Installer/Daybreak.Installer
|
||||
mv Installer/Daybreak.Installer Installer/Daybreak.Installer.Temp
|
||||
fi
|
||||
chmod +x Daybreak Daybreak.Wayland.sh
|
||||
cd ..
|
||||
cd linux-publish && zip -r ../daybreakv${{ env.Version }}-linux.zip . && cd ..
|
||||
|
||||
- name: Upload release artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-zips
|
||||
path: |
|
||||
daybreakv${{ env.Version }}.zip
|
||||
daybreakv${{ env.Version }}-linux.zip
|
||||
retention-days: 7
|
||||
|
||||
# ───────────────────────────────────────────────────────
|
||||
# Stage 4 — Release (optional)
|
||||
# ───────────────────────────────────────────────────────
|
||||
release:
|
||||
needs: [build-x86, package]
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ inputs.publishRelease }}
|
||||
|
||||
env:
|
||||
Actions_Allow_Unsecure_Commands: true
|
||||
Version: ${{ needs.build-x86.outputs.version }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Download release artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: release-zips
|
||||
path: release-assets/
|
||||
|
||||
- name: Get Latest Tag
|
||||
id: getLatestTag
|
||||
uses: WyriHaximus/github-action-get-previous-tag@v2
|
||||
|
||||
- name: Generate changelog
|
||||
id: changelog
|
||||
run: |
|
||||
changeLog=$(git log --no-merges --pretty="%h - %s (%an)<br />" ${{ steps.getLatestTag.outputs.tag }}..HEAD)
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$changeLog" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
# ── Release ──
|
||||
- name: Create release draft
|
||||
uses: Xotl/cool-github-releases@v1.1.10
|
||||
with:
|
||||
mode: update
|
||||
tag_name: v${{ env.Version }}
|
||||
release_name: Daybreak v${{ env.Version }}
|
||||
assets: release-assets/daybreakv${{ env.Version }}.zip;release-assets/daybreakv${{ env.Version }}-linux.zip
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
replace_assets: true
|
||||
body_mrkdwn: |
|
||||
${{ steps.changelog.outputs.content }}
|
||||
isDraft: true
|
||||
|
||||
- name: Publish release
|
||||
run: gh release edit v${{ env.Version }} --draft=false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
+20
-97
@@ -1,106 +1,29 @@
|
||||
# Copyright (c) Microsoft Corporation.
|
||||
# Licensed under the MIT license.
|
||||
|
||||
# This continuous integration pipeline is triggered anytime a user pushes code to the repo.
|
||||
# This pipeline builds the Wpf project, runs unit tests, then saves the MSIX build artifact.
|
||||
name: Daybreak CD Pipeline
|
||||
# This continuous delivery pipeline is triggered on pushes to master.
|
||||
# It builds the Windows x86 components (Injector + API), then builds
|
||||
# the Windows x64 and Linux x64 platform bundles in parallel, and
|
||||
# finally packages both into release zips and publishes a GitHub release.
|
||||
name: Daybreak CD Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "Daybreak/**"
|
||||
- "Daybreak.Core/**"
|
||||
- "Daybreak.Installer/**"
|
||||
- "Daybreak.API/**"
|
||||
- "Daybreak.Shared/**"
|
||||
- "Daybreak.Windows/**"
|
||||
- "Daybreak.Linux/**"
|
||||
- ".github/scripts/**"
|
||||
- ".github/workflows/cd-template.yml"
|
||||
- ".github/workflows/cd.yaml"
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
jobs:
|
||||
|
||||
build:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
targetplatform: [x64]
|
||||
|
||||
runs-on: windows-latest
|
||||
|
||||
env:
|
||||
Configuration: Release
|
||||
Solution_Path: Daybreak.sln
|
||||
Test_Project_Path: Daybreak.Tests\Daybreak.Tests.csproj
|
||||
Wpf_Project_Path: Daybreak\Daybreak.csproj
|
||||
Actions_Allow_Unsecure_Commands: true
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get Latest Tag
|
||||
id: getLatestTag
|
||||
uses: WyriHaximus/github-action-get-previous-tag@v1
|
||||
|
||||
- name: Generate changelog
|
||||
id: gen_changelog
|
||||
run: |
|
||||
$changeLog = git log --no-merges --pretty="%h - %s (%an)<br />" ${{ env.LatestReleaseTag }}..HEAD
|
||||
echo "::set-env name=Changelog::$changeLog"
|
||||
env:
|
||||
LatestReleaseTag: ${{steps.getLatestTag.outputs.tag}}
|
||||
|
||||
- name: Install .NET Core
|
||||
uses: actions/setup-dotnet@v3
|
||||
with:
|
||||
dotnet-version: '7.x'
|
||||
|
||||
- name: Setup MSBuild.exe
|
||||
uses: microsoft/setup-msbuild@v1.3.1
|
||||
|
||||
- name: Setup project secrets
|
||||
run: |
|
||||
dotnet user-secrets --project Daybreak\Daybreak.csproj set AadApplicationId "${{ secrets.AadApplicationId }}"
|
||||
dotnet user-secrets --project Daybreak\Daybreak.csproj set AadTenantId "${{ secrets.AadTenantId }}"
|
||||
|
||||
- name: Restore project
|
||||
run: msbuild $env:Solution_Path /t:Restore /p:Configuration=$env:Configuration /p:RuntimeIdentifier=$env:RuntimeIdentifier
|
||||
env:
|
||||
RuntimeIdentifier: win-${{ matrix.targetplatform }}
|
||||
|
||||
- name: Build Daybreak project
|
||||
run: dotnet build Daybreak -c $env:Configuration
|
||||
|
||||
- name: Set version variable
|
||||
run: |
|
||||
$version = .\Scripts\GetBuildVersion.ps1
|
||||
echo "::set-env name=Version::$version"
|
||||
|
||||
- name: Create publish launcher files
|
||||
run: dotnet publish .\Daybreak\Daybreak.csproj -c $env:Configuration -r $env:RuntimeIdentifier -p:PublishReadyToRun=true -p:PublishSingleFile=true --self-contained true -o .\Publish
|
||||
env:
|
||||
RuntimeIdentifier: win-${{ matrix.targetplatform }}
|
||||
|
||||
- name: Create publish installer files
|
||||
run: dotnet publish .\Daybreak.Installer\Daybreak.Installer.csproj -c $env:Configuration -r $env:RuntimeIdentifier -p:PublishReadyToRun=true -p:PublishSingleFile=true --self-contained true -o .\Publish
|
||||
env:
|
||||
RuntimeIdentifier: win-${{ matrix.targetplatform }}
|
||||
|
||||
- name: Pack publish files
|
||||
run: |
|
||||
Write-Host $env
|
||||
.\Scripts\BuildRelease.ps1 -version $env:Version
|
||||
shell: pwsh
|
||||
|
||||
- name: Publish release
|
||||
uses: Xotl/cool-github-releases@v1.1.8
|
||||
with:
|
||||
mode: update
|
||||
tag_name: v${{ env.Version }}
|
||||
release_name: Daybreak v${{ env.Version }}
|
||||
assets: .\Publish\daybreakv${{ env.Version }}.zip
|
||||
github_token: ${{ env.GITHUB_TOKEN }}
|
||||
replace_assets: true
|
||||
body_mrkdwn: ${{ env.Changelog }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
cd:
|
||||
uses: ./.github/workflows/cd-template.yml
|
||||
with:
|
||||
publishRelease: true
|
||||
signBinaries: true
|
||||
secrets: inherit
|
||||
|
||||
+50
-37
@@ -1,53 +1,66 @@
|
||||
# Copyright (c) Microsoft Corporation.
|
||||
# Licensed under the MIT license.
|
||||
|
||||
# This continuous integration pipeline is triggered anytime a user pushes code to the repo.
|
||||
# This pipeline builds the Wpf project, runs unit tests, then saves the MSIX build artifact.
|
||||
name: Daybreak CI Pipeline
|
||||
# This continuous integration pipeline is triggered on pull requests to master.
|
||||
# It validates that both the Windows and Linux builds compile successfully.
|
||||
name: Daybreak CI Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
|
||||
build:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
targetplatform: [x64]
|
||||
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
|
||||
env:
|
||||
Solution_Path: Daybreak.sln
|
||||
Test_Project_Path: Daybreak.Tests\Daybreak.Tests.csproj
|
||||
Wpf_Project_Path: Daybreak\Daybreak.csproj
|
||||
Actions_Allow_Unsecure_Commands: true
|
||||
Configuration: Debug
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Install .NET Core
|
||||
uses: actions/setup-dotnet@v3
|
||||
with:
|
||||
dotnet-version: '7.x'
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: "10.x"
|
||||
|
||||
- name: Setup MSBuild.exe
|
||||
uses: microsoft/setup-msbuild@v1.3.1
|
||||
- name: Build Windows project
|
||||
run: dotnet build Daybreak.Windows/Daybreak.Windows.csproj -c $env:Configuration
|
||||
|
||||
- name: Execute Unit Tests
|
||||
run: dotnet test $env:Test_Project_Path
|
||||
- name: Build Injector
|
||||
run: dotnet build Daybreak.Injector/Daybreak.Injector.csproj -c $env:Configuration
|
||||
|
||||
- name: Restore the Wpf application to populate the obj folder
|
||||
run: msbuild $env:Solution_Path /t:Restore /p:Configuration=$env:Configuration /p:RuntimeIdentifier=$env:RuntimeIdentifier
|
||||
env:
|
||||
Configuration: Debug
|
||||
RuntimeIdentifier: win-${{ matrix.targetplatform }}
|
||||
- name: Build API
|
||||
run: dotnet build Daybreak.API/Daybreak.API.csproj -c $env:Configuration
|
||||
|
||||
- name: Build Installer
|
||||
run: dotnet build Daybreak.Installer/Daybreak.Installer.csproj -c $env:Configuration
|
||||
|
||||
- name: Run tests
|
||||
run: dotnet test Daybreak.Tests/Daybreak.Tests.csproj -c $env:Configuration
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
Configuration: Debug
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: "10.x"
|
||||
|
||||
- name: Build Linux project
|
||||
run: dotnet build Daybreak.Linux/Daybreak.Linux.csproj -c $Configuration
|
||||
|
||||
- name: Build Installer (linux-x64)
|
||||
run: dotnet build Daybreak.Installer/Daybreak.Installer.csproj -c $Configuration
|
||||
@@ -12,10 +12,7 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ "master" ]
|
||||
schedule:
|
||||
- cron: '41 12 * * 6'
|
||||
@@ -36,13 +33,22 @@ jobs:
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||
|
||||
env:
|
||||
Configuration: Release
|
||||
Solution_Path: Daybreak.slnx
|
||||
Test_Project_Path: Daybreak.Tests\Daybreak.Tests.csproj
|
||||
Wpf_Project_Path: Daybreak.Windows\Daybreak.Windows.csproj
|
||||
Actions_Allow_Unsecure_Commands: true
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -57,12 +63,12 @@ jobs:
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
|
||||
- name: Install .NET Core
|
||||
uses: actions/setup-dotnet@v3
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: '7.x'
|
||||
dotnet-version: '10.x'
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
#- name: Autobuild
|
||||
# uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
@@ -74,5 +80,8 @@ jobs:
|
||||
# echo "Run, Build Application using script"
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Build Daybreak project
|
||||
run: dotnet build Daybreak.Windows -c $env:Configuration --property:SolutionDir=$env:GITHUB_WORKSPACE
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
uses: github/codeql-action/analyze@v3
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Manually trigger to create a new release branch with bumped build version
|
||||
name: Create Build Release Branch
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
uses: ./.github/workflows/create-release-branch-template.yml
|
||||
with:
|
||||
versionBump: "*.*.^.0"
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,12 @@
|
||||
# Manually trigger to create a new release branch with bumped major version
|
||||
name: Create Major Release Branch
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
uses: ./.github/workflows/create-release-branch-template.yml
|
||||
with:
|
||||
versionBump: "^.0.0.0"
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,12 @@
|
||||
# Manually trigger to create a new release branch with bumped minor version
|
||||
name: Create Minor Release Branch
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
uses: ./.github/workflows/create-release-branch-template.yml
|
||||
with:
|
||||
versionBump: "*.^.0.0"
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,66 @@
|
||||
# Reusable template for creating release branches with version bumps
|
||||
name: Create Release Branch Template
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
versionBump:
|
||||
description: "Version bump command: bump-major, bump-minor, bump-build, bump-revision"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
create-release-branch:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.RELEASE_PAT }}
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Bump version in Directory.Build.props
|
||||
id: bump
|
||||
uses: vers-one/dotnet-project-version-updater@v1.8
|
||||
with:
|
||||
file: "Directory.Build.props"
|
||||
version: ${{ inputs.versionBump }}
|
||||
|
||||
- name: Check if release branch already exists
|
||||
id: check_branch
|
||||
run: |
|
||||
branch_name="release/${{ steps.bump.outputs.newVersion }}"
|
||||
if git ls-remote --heads origin "$branch_name" | grep -q "$branch_name"; then
|
||||
echo "Branch $branch_name already exists"
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Branch $branch_name does not exist"
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Create release branch
|
||||
if: steps.check_branch.outputs.exists == 'false'
|
||||
run: |
|
||||
new_version="${{ steps.bump.outputs.newVersion }}"
|
||||
branch_name="release/$new_version"
|
||||
|
||||
# Create new branch from master
|
||||
git checkout -b "$branch_name"
|
||||
|
||||
# Commit the version change (already updated by the action)
|
||||
git add Directory.Build.props
|
||||
git commit -m "Bump version to $new_version"
|
||||
|
||||
# Push the new branch
|
||||
git push origin "$branch_name"
|
||||
|
||||
echo "✅ Created release branch: $branch_name with version $new_version"
|
||||
@@ -0,0 +1,20 @@
|
||||
# Automatically creates a new release branch with bumped revision
|
||||
# after a successful CD pipeline run on master
|
||||
name: Create Revision Release Branch
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Daybreak CD Pipeline"]
|
||||
types:
|
||||
- completed
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
# Only run if the CD pipeline succeeded
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
uses: ./.github/workflows/create-release-branch-template.yml
|
||||
with:
|
||||
versionBump: bump-revision
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,47 @@
|
||||
name: Label Merged Issues
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- 'release/**'
|
||||
|
||||
jobs:
|
||||
label-issues:
|
||||
if: github.event.pull_request.merged == true
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Label linked issues as merged
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const prBody = context.payload.pull_request.body || '';
|
||||
const prTitle = context.payload.pull_request.title || '';
|
||||
|
||||
// Match issue references like #123, fixes #123, closes #123, resolves #123
|
||||
const issuePattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)?[\s:]*#(\d+)/gi;
|
||||
const issueNumbers = new Set();
|
||||
|
||||
// Search in PR body and title
|
||||
const textToSearch = `${prTitle} ${prBody}`;
|
||||
let match;
|
||||
while ((match = issuePattern.exec(textToSearch)) !== null) {
|
||||
issueNumbers.add(parseInt(match[1]));
|
||||
}
|
||||
|
||||
// Apply 'merged' label to each linked issue
|
||||
for (const issueNumber of issueNumbers) {
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
labels: ['merged']
|
||||
});
|
||||
console.log(`Added 'merged' label to issue #${issueNumber}`);
|
||||
} catch (error) {
|
||||
console.log(`Failed to label issue #${issueNumber}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,55 @@
|
||||
name: Daybreak Version Check
|
||||
name: Daybreak Version Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "Daybreak/**"
|
||||
- "Daybreak.Core/**"
|
||||
- "Daybreak.Installer/**"
|
||||
- "Daybreak.API/**"
|
||||
- "Daybreak.Shared/**"
|
||||
- "Daybreak.Windows/**"
|
||||
- "Daybreak.Linux/**"
|
||||
|
||||
jobs:
|
||||
|
||||
check_version:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
targetplatform: [x64]
|
||||
|
||||
runs-on: windows-latest
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
Configuration: Release
|
||||
Solution_Path: Daybreak.sln
|
||||
Solution_Path: Daybreak.slnx
|
||||
Actions_Allow_Unsecure_Commands: true
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get Latest Tag
|
||||
id: getLatestTag
|
||||
uses: WyriHaximus/github-action-get-previous-tag@v1
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build Daybreak project
|
||||
run: dotnet build Daybreak -c $env:Configuration
|
||||
- name: Install .NET Core
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: "10.x"
|
||||
|
||||
- name: Set version variable
|
||||
run: |
|
||||
$version = .\Scripts\GetBuildVersion.ps1
|
||||
echo "::set-env name=Version::$version"
|
||||
- name: Get Latest Tag
|
||||
id: getLatestTag
|
||||
uses: WyriHaximus/github-action-get-previous-tag@v2
|
||||
|
||||
- name: Build Daybreak project
|
||||
run: dotnet build Daybreak.Linux -c $Configuration --property:SolutionDir=$GITHUB_WORKSPACE
|
||||
|
||||
- name: Get version
|
||||
id: get_version
|
||||
run: |
|
||||
version=$(grep '<Version>' Directory.Build.props | sed 's/.*<Version>\(.*\)<\/Version>.*/\1/')
|
||||
echo "version=$version" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check version difference
|
||||
run: pwsh ./Scripts/CompareVersions.ps1 -currentVersion ${{ steps.get_version.outputs.version }} -lastVersion ${{ steps.getLatestTag.outputs.tag }}
|
||||
shell: pwsh
|
||||
|
||||
- name: Check version difference
|
||||
run: |
|
||||
.\Scripts\CompareVersions -currentVersion ${{ env.Version }} -lastVersion ${{ env.LatestReleaseTag }}
|
||||
env:
|
||||
LatestReleaseTag: ${{ steps.getLatestTag.outputs.tag }}
|
||||
+11
-3
@@ -28,12 +28,16 @@ x86/
|
||||
[Aa][Rr][Mm]/
|
||||
[Aa][Rr][Mm]64/
|
||||
bld/
|
||||
build/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Oo]ut/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Local NuGet packages folder (per-machine isolation)
|
||||
.packages/
|
||||
|
||||
# Visual Studio 2015/2017 cache/options directory
|
||||
.vs/
|
||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||
@@ -177,8 +181,8 @@ DocProject/Help/*.hhp
|
||||
DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
# Click-Once / local publish directories
|
||||
[Pp]ublish/
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
@@ -361,4 +365,8 @@ MigrationBackup/
|
||||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
FodyWeavers.xsd
|
||||
|
||||
.idea
|
||||
|
||||
Daybreak/wwwroot/wiki/*
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "Daybreak.wiki"]
|
||||
path = Daybreak.wiki
|
||||
url = https://github.com/gwdevhub/Daybreak.wiki.git
|
||||
@@ -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', '-c', 'Debug' },
|
||||
{ cwd = repo })
|
||||
vim.cmd('startinsert')
|
||||
end, { desc = 'Run Daybreak.Linux via dotnet run in the current window' })
|
||||
Vendored
+45
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Debug Daybreak.Linux",
|
||||
"type": "dotnet",
|
||||
"request": "launch",
|
||||
"projectPath": "${workspaceFolder}/Daybreak.Linux/Daybreak.Linux.csproj",
|
||||
"console": "integratedTerminal",
|
||||
"justMyCode": true,
|
||||
"stopAtEntry": false,
|
||||
"suppressJITOptimizations": false,
|
||||
"symbolOptions": {
|
||||
"searchMicrosoftSymbolServer": false,
|
||||
"searchNuGetOrgSymbolServer": false
|
||||
},
|
||||
"logging": {
|
||||
"engineLogging": false,
|
||||
"moduleLoad": false,
|
||||
"exceptions": false,
|
||||
"browserStdOut": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Debug Daybreak.Windows",
|
||||
"type": "dotnet",
|
||||
"request": "launch",
|
||||
"projectPath": "${workspaceFolder}/Daybreak.Windows/Daybreak.Windows.csproj",
|
||||
"console": "integratedTerminal",
|
||||
"justMyCode": true,
|
||||
"stopAtEntry": false,
|
||||
"suppressJITOptimizations": false,
|
||||
"symbolOptions": {
|
||||
"searchMicrosoftSymbolServer": false,
|
||||
"searchNuGetOrgSymbolServer": false
|
||||
},
|
||||
"logging": {
|
||||
"engineLogging": false,
|
||||
"moduleLoad": false,
|
||||
"exceptions": false,
|
||||
"browserStdOut": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"dotnet.defaultSolution": "Daybreak.slnx",
|
||||
"omnisharp.projectLoadTimeout": 120,
|
||||
"csharp.semanticHighlighting.enabled": true,
|
||||
"search.followSymlinks": false,
|
||||
"search.exclude": {
|
||||
"**/.git": true,
|
||||
"**/.vs": true,
|
||||
"**/bin": true,
|
||||
"**/obj": true,
|
||||
"**/node_modules": true,
|
||||
"**/.idea": true,
|
||||
"**/Publish": true,
|
||||
},
|
||||
"files.watcherExclude": {
|
||||
"**/.git/**": true,
|
||||
"**/.vs/**": true,
|
||||
"**/bin/**": true,
|
||||
"**/obj/**": true,
|
||||
"**/node_modules/**": true,
|
||||
"**/Publish": true,
|
||||
},
|
||||
"files.exclude": {
|
||||
"**/.vs": true,
|
||||
"**/bin": true,
|
||||
"**/obj": true,
|
||||
"**/Publish": true
|
||||
}
|
||||
}
|
||||
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "build-linux",
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
"build",
|
||||
"${workspaceFolder}/Daybreak.Linux/Daybreak.Linux.csproj",
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary"
|
||||
],
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
"label": "build-windows",
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
"build",
|
||||
"${workspaceFolder}/Daybreak.Windows/Daybreak.Windows.csproj",
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary"
|
||||
],
|
||||
"problemMatcher": "$msCompile"
|
||||
}
|
||||
]
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
# Contributing to Daybreak
|
||||
|
||||
## Getting started
|
||||
|
||||
Install the prerequisites listed under
|
||||
[Build Requirements](README.md#build-requirements), then clone with submodules:
|
||||
|
||||
```bash
|
||||
git clone --recurse-submodules https://github.com/AlexMacocian/Daybreak.git
|
||||
```
|
||||
|
||||
Read the [Architecture Overview](README.md#architecture-overview) before making
|
||||
changes so you put code in the right project.
|
||||
|
||||
## Branching model
|
||||
|
||||
There is always exactly one active release branch, named `release/[version]`
|
||||
(for example `release/0.9.10.20`). It is the integration branch: all work
|
||||
targets it, and `master` only receives finished releases.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph cycle["Release cycle"]
|
||||
direction LR
|
||||
R["release/0.9.10.20<br/><i>active release branch</i>"]
|
||||
F["feature branch<br/><i>one per change</i>"]
|
||||
R -->|fork| F
|
||||
F -->|"PR, squash merge"| R
|
||||
end
|
||||
R -->|"PR, rebase merge"| M["master"]
|
||||
M --> CD["CD pipeline<br/>builds and publishes the release"]
|
||||
CD -->|"job bumps the revision"| N["release/0.9.10.21<br/><i>next active release branch</i>"]
|
||||
```
|
||||
|
||||
### Feature work
|
||||
|
||||
1. Branch off the current release branch.
|
||||
2. Implement one feature or fix. Keep the branch scoped to a single change.
|
||||
3. Open a PR against the release branch.
|
||||
4. Merge with **squash**, so each feature lands as one commit.
|
||||
|
||||
### Releasing
|
||||
|
||||
1. Open a PR from the release branch onto `master`.
|
||||
2. Merge with **rebase**, keeping full history. Each feature stays a single
|
||||
commit on `master`.
|
||||
3. The [CD pipeline](.github/workflows/cd.yaml) runs on the push to `master`,
|
||||
builds the Windows and Linux bundles and publishes a GitHub release.
|
||||
4. On success,
|
||||
[create-revision-release-branch.yml](.github/workflows/create-revision-release-branch.yml)
|
||||
bumps the revision and creates the next `release/[version]` branch
|
||||
automatically. That becomes the new active release branch.
|
||||
|
||||
Do not push directly to `master` or create release branches by hand.
|
||||
|
||||
## Versions
|
||||
|
||||
The version lives in a single place,
|
||||
[`Directory.Build.props`](Directory.Build.props), and follows
|
||||
`major.minor.build.revision`. It is bumped by the release branch creation job,
|
||||
not manually.
|
||||
|
||||
Revision bumps happen automatically after every release. For the other
|
||||
components, run the matching workflow manually before starting the next cycle:
|
||||
|
||||
| Workflow | Bumps |
|
||||
| ------------------------------------------------------------------------------------------ | --------------------- |
|
||||
| [create-major-release-branch.yml](.github/workflows/create-major-release-branch.yml) | `^.0.0.0` |
|
||||
| [create-minor-release-branch.yml](.github/workflows/create-minor-release-branch.yml) | `*.^.0.0` |
|
||||
| [create-build-release-branch.yml](.github/workflows/create-build-release-branch.yml) | `*.*.^.0` |
|
||||
| [create-revision-release-branch.yml](.github/workflows/create-revision-release-branch.yml) | `*.*.*.^` (automatic) |
|
||||
|
||||
All four delegate to
|
||||
[create-release-branch-template.yml](.github/workflows/create-release-branch-template.yml).
|
||||
|
||||
## Pull requests
|
||||
|
||||
Title format follows the existing history: a short description, plus the issue
|
||||
reference when it closes one.
|
||||
|
||||
```text
|
||||
Setup stable computer name in Wine prefix (Closes #1609)
|
||||
```
|
||||
|
||||
[label-merged-issues.yaml](.github/workflows/label-merged-issues.yaml) parses
|
||||
`close/fix/resolve #N` from the title and body of PRs merged into a release
|
||||
branch and labels the linked issues, so use those keywords.
|
||||
|
||||
## Checks
|
||||
|
||||
[CI](.github/workflows/ci.yaml) and
|
||||
[version_check.yaml](.github/workflows/version_check.yaml) only trigger on PRs
|
||||
targeting `master`, so a feature PR onto a release branch is not covered by
|
||||
them. Run the equivalent locally before opening one:
|
||||
|
||||
```bash
|
||||
dotnet build Daybreak.Linux/Daybreak.Linux.csproj # or Daybreak.Windows on Windows
|
||||
dotnet test Daybreak.Tests/Daybreak.Tests.csproj
|
||||
```
|
||||
|
||||
The version check fails a PR to `master` if the version in
|
||||
`Directory.Build.props` is not ahead of the latest tag. This is why the bump
|
||||
must come from the release branch job.
|
||||
|
||||
## Code style
|
||||
|
||||
Formatting rules are in [`.editorconfig`](.editorconfig) and are enforced by the
|
||||
compiler and analyzers; keep the build warning-free.
|
||||
|
||||
Tests live in [`Daybreak.Tests`](Daybreak.Tests), use MSTest with
|
||||
FluentAssertions and NSubstitute, and only reference `Daybreak.Core`. If you
|
||||
want platform code covered by tests, put the testable part in
|
||||
[`Daybreak.Shared`](Daybreak.Shared).
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Daybreak.API.Configuration;
|
||||
|
||||
public static class BuildInfo
|
||||
{
|
||||
public const string Configuration =
|
||||
#if DEBUG
|
||||
"Debug";
|
||||
#else
|
||||
"Release";
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace Daybreak.API.Configuration;
|
||||
|
||||
public static class WebApplicationBuilderExtensions
|
||||
{
|
||||
public static WebApplicationBuilder WithConfiguration(this WebApplicationBuilder builder)
|
||||
{
|
||||
var config = BuildInfo.Configuration;
|
||||
var appSettingsStream = GetManifestResourceStream($"Daybreak.API.Configuration.appsettings.json") ?? throw new InvalidOperationException("Failed to load appsettings.json");
|
||||
var appSettingsConfigStream = GetManifestResourceStream($"Daybreak.API.Configuration.appsettings.{config}.json") ?? throw new InvalidOperationException($"Failed to load appsettings.{config}.json");
|
||||
|
||||
// Clear default file-based configuration sources that cause unnecessary reload events
|
||||
builder.Configuration.Sources.Clear();
|
||||
|
||||
builder.Configuration
|
||||
.AddJsonStream(appSettingsStream)
|
||||
.AddJsonStream(appSettingsConfigStream)
|
||||
.AddEnvironmentVariables();
|
||||
|
||||
builder.WebHost.UseSetting(WebHostDefaults.PreventHostingStartupKey, "true");
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static Stream? GetManifestResourceStream(string name)
|
||||
{
|
||||
var assembly = typeof(WebApplicationBuilderExtensions).Assembly;
|
||||
var resourceName = assembly.GetManifestResourceNames()
|
||||
.FirstOrDefault(n => n.EndsWith(name, StringComparison.OrdinalIgnoreCase));
|
||||
if (resourceName is null)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
return assembly.GetManifestResourceStream(resourceName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"Logging": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Debug",
|
||||
"Override": {
|
||||
"Microsoft": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"Logging": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Debug",
|
||||
"Override": {
|
||||
"Microsoft": "Debug"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Correlation": {
|
||||
"Header": "X-Correlation-Vector"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Core.Extensions;
|
||||
using Daybreak.API.Interop.GuildWars;
|
||||
using Daybreak.API.Services;
|
||||
using Daybreak.Shared.Models.Api;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Net.Sdk.Web;
|
||||
|
||||
namespace Daybreak.API.Controllers.Rest;
|
||||
|
||||
[GenerateController("api/v1/rest/character-select")]
|
||||
[Tags("Character Select")]
|
||||
public sealed class CharacterSelectController(
|
||||
CharacterSelectService characterSelectService)
|
||||
{
|
||||
private readonly CharacterSelectService characterSelectService = characterSelectService.ThrowIfNull();
|
||||
|
||||
[GenerateGet]
|
||||
[EndpointName("GetCharacterSelectInformation")]
|
||||
[EndpointSummary("Get Character Select Information")]
|
||||
[EndpointDescription("Get the character select information for the current player. Returns a serialized CharacterSelectInformation object")]
|
||||
[ProducesResponseType<CharacterSelectInformation>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
public async Task<IResult> GetCharacterSelectInformation(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var characterSelectInfo = await this.characterSelectService.GetCharacterSelectInformation(cancellationToken);
|
||||
return characterSelectInfo is null ?
|
||||
Results.StatusCode(StatusCodes.Status503ServiceUnavailable) :
|
||||
Results.Ok(characterSelectInfo);
|
||||
}
|
||||
|
||||
[GeneratePost("{identifier}")]
|
||||
[EndpointName("ChangeCharacter")]
|
||||
[EndpointSummary("Change Character by identifier")]
|
||||
[EndpointDescription("Logs out from the current character and tries to log in with the desired character. Identifier can be either uuid or character name")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
public async Task<IResult> ChangeCharacter(
|
||||
[FromRoute] string identifier,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var isUuid = Uuid.TryParse(identifier, out _);
|
||||
var result = isUuid
|
||||
? await this.characterSelectService.ChangeCharacterByUuid(identifier, cancellationToken)
|
||||
: await this.characterSelectService.ChangeCharacterByName(identifier, cancellationToken);
|
||||
return result ?
|
||||
Results.Ok() :
|
||||
Results.StatusCode(StatusCodes.Status500InternalServerError);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Daybreak.API.Services;
|
||||
using Daybreak.Shared.Models.Api;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Net.Sdk.Web;
|
||||
|
||||
namespace Daybreak.API.Controllers.Rest;
|
||||
|
||||
[GenerateController("api/v1/rest/inventory")]
|
||||
[Tags("Inventory")]
|
||||
public sealed class InventoryController(
|
||||
InventoryService inventoryService)
|
||||
{
|
||||
private readonly InventoryService inventoryService = inventoryService;
|
||||
|
||||
[GenerateGet]
|
||||
[EndpointName("GetInventory")]
|
||||
[EndpointSummary("Get Inventory information")]
|
||||
[EndpointDescription("Get the inventory of the current player. Returns a serialized InventoryInformation object")]
|
||||
[ProducesResponseType<InventoryInformation>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
public async Task<IResult> GetInventory(CancellationToken cancellationToken)
|
||||
{
|
||||
var inventoryInformation = await this.inventoryService.GetInventoryInformation(cancellationToken);
|
||||
return inventoryInformation is null ?
|
||||
Results.StatusCode(StatusCodes.Status503ServiceUnavailable) :
|
||||
Results.Ok(inventoryInformation);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Core.Extensions;
|
||||
using Daybreak.API.Services;
|
||||
using Daybreak.Shared.Models.Api;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Net.Sdk.Web;
|
||||
|
||||
namespace Daybreak.API.Controllers.Rest;
|
||||
|
||||
[GenerateController("api/v1/rest/login")]
|
||||
[Tags("Login")]
|
||||
public sealed class LoginController(
|
||||
LoginService loginService)
|
||||
{
|
||||
private readonly LoginService loginService = loginService.ThrowIfNull();
|
||||
|
||||
[GenerateGet]
|
||||
[EndpointName("GetLoginInfo")]
|
||||
[EndpointSummary("Get Login Information")]
|
||||
[EndpointDescription("Get the current login information. Returns a serialized LoginInfo object")]
|
||||
[ProducesResponseType<LoginInfo>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
public async Task<IResult> GetLoginInfo(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var loginInfo = await this.loginService.GetLoginInformation(cancellationToken);
|
||||
return loginInfo is null ?
|
||||
Results.StatusCode(StatusCodes.Status503ServiceUnavailable) :
|
||||
Results.Ok(loginInfo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using System.Core.Extensions;
|
||||
using Daybreak.API.Services;
|
||||
using Daybreak.Shared.Models.Api;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Net.Sdk.Web;
|
||||
|
||||
namespace Daybreak.API.Controllers.Rest;
|
||||
|
||||
[GenerateController("api/v1/rest/main-player")]
|
||||
[Tags("Main Player")]
|
||||
public sealed class MainPlayerController(
|
||||
MainPlayerService mainPlayerService,
|
||||
ILogger<MainPlayerController> logger)
|
||||
{
|
||||
private readonly MainPlayerService mainPlayerService = mainPlayerService.ThrowIfNull();
|
||||
private readonly ILogger<MainPlayerController> logger = logger.ThrowIfNull();
|
||||
|
||||
[GenerateGet("state")]
|
||||
[EndpointName("GetMainPlayerState")]
|
||||
[EndpointSummary("Get the current MainPlayerState")]
|
||||
[EndpointDescription("Get the current MainPlayerState. Returns a json serialized MainPlayerState object. You can use the websocket endpoint to subscribe to MainPlayerState updates on each game thread proc")]
|
||||
[ProducesResponseType<MainPlayerState>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
public async Task<IResult> GetMainPlayerState(CancellationToken cancellationToken)
|
||||
{
|
||||
var mainPlayerState = await this.mainPlayerService.GetMainPlayerState(cancellationToken);
|
||||
return mainPlayerState is not null ? Results.Ok(mainPlayerState) : Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
[GenerateGet("quest-log")]
|
||||
[EndpointName("GetQuestLog")]
|
||||
[EndpointSummary("Get the current Quest Log")]
|
||||
[EndpointDescription("Get the current Quest Log. Returns a json serialized QuestLogInformation object")]
|
||||
[ProducesResponseType<QuestLogInformation>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
public async Task<IResult> GetQuestLog(CancellationToken cancellationToken)
|
||||
{
|
||||
var questLog = await this.mainPlayerService.GetQuestLog(cancellationToken);
|
||||
return questLog is not null ? Results.Ok(questLog) : Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
[GenerateGet("info")]
|
||||
[EndpointName("GetMainPlayerInformation")]
|
||||
[EndpointSummary("Get the current MainPlayerInformation")]
|
||||
[EndpointDescription("Get the current MainPlayerInformation. Returns a json serialized MainPlayerInformation object")]
|
||||
[ProducesResponseType<MainPlayerInformation>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
public async Task<IResult> GetMainPlayerInformation(CancellationToken cancellationToken)
|
||||
{
|
||||
var mainPlayerInformation = await this.mainPlayerService.GetMainPlayerInformation(cancellationToken);
|
||||
return mainPlayerInformation is not null ? Results.Ok(mainPlayerInformation) : Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
[GenerateGet("build")]
|
||||
[EndpointName("GetMainPlayerBuild")]
|
||||
[EndpointSummary("Get the current build")]
|
||||
[EndpointDescription("Get the current build. Returns a json serialized BuildEntry object")]
|
||||
[ProducesResponseType<BuildEntry>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
public async Task<IResult> GetMainPlayerBuild(CancellationToken cancellationToken)
|
||||
{
|
||||
var build = await this.mainPlayerService.GetCurrentBuild(cancellationToken);
|
||||
return build is not null ? Results.Ok(build) : Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
[GeneratePost("build")]
|
||||
[EndpointName("SetMainPlayerBuild")]
|
||||
[EndpointSummary("Set the current build")]
|
||||
[EndpointDescription("Set the current build. Returns 200 is the operation has succeeded")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
public async Task<IResult> SetMainPlayerBuild([FromQuery(Name = "code")] string? code, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await this.mainPlayerService.SetCurrentBuild(code ?? string.Empty, cancellationToken);
|
||||
return result ? Results.Ok() : Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
[GenerateGet("instance-info")]
|
||||
[EndpointName("GetMainPlayerInstanceInfo")]
|
||||
[EndpointSummary("Get the current instance info")]
|
||||
[EndpointDescription("Get the current instance info. Returns a json serialized MainPlayerInstanceInfo object")]
|
||||
[ProducesResponseType<InstanceInfo>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
public async Task<IResult> GetMainPlayerInstanceInfo(CancellationToken cancellationToken)
|
||||
{
|
||||
var instanceInfo = await this.mainPlayerService.GetMainPlayerInstance(cancellationToken);
|
||||
return instanceInfo is not null ? Results.Ok(instanceInfo) : Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
[GenerateGet("title")]
|
||||
[EndpointName("GetMainPlayerTitleInfo")]
|
||||
[EndpointSummary("Get the current title info")]
|
||||
[EndpointDescription("Get the current title info. Returns a json serialized TitleInfo object")]
|
||||
[ProducesResponseType<TitleInfo>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
public async Task<IResult> GetMainPlayerTitleInfo(CancellationToken cancellationToken)
|
||||
{
|
||||
var titleInfo = await this.mainPlayerService.GetTitleInfo(cancellationToken);
|
||||
return titleInfo is not null ? Results.Ok(titleInfo) : Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
[GenerateGet("build-context")]
|
||||
[EndpointName("GetMainPlayerBuildContext")]
|
||||
[EndpointSummary("Get the current build context")]
|
||||
[EndpointDescription("Get the current build context. Returns a json serialized MainPlayerBuildContext object")]
|
||||
[ProducesResponseType<BuildEntry>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
public async Task<IResult> GetMainPlayerBuildContext(CancellationToken cancellationToken)
|
||||
{
|
||||
var context = await this.mainPlayerService.GetMainPlayerBuildContext(cancellationToken);
|
||||
return context is not null ? Results.Ok(context) : Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Daybreak.API.Services;
|
||||
using Daybreak.Shared.Models.Api;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Net.Sdk.Web;
|
||||
|
||||
namespace Daybreak.API.Controllers.Rest;
|
||||
|
||||
[GenerateController("api/v1/rest/party")]
|
||||
[Tags("Party")]
|
||||
public sealed class PartyController(PartyService partyService)
|
||||
{
|
||||
private readonly PartyService partyService = partyService;
|
||||
|
||||
[GenerateGet("loadout")]
|
||||
[EndpointName("GetPartyLoadout")]
|
||||
[EndpointSummary("Get the current party loadout")]
|
||||
[EndpointDescription("Get the current party loadout. Returns a json serialized PartyLoadout object")]
|
||||
[ProducesResponseType<PartyLoadout>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
public async Task<IResult> GetPartyLoadout(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var partyLoadout = await this.partyService.GetPartyLoadout(cancellationToken);
|
||||
return partyLoadout is not null ? Results.Ok(partyLoadout) : Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
[GeneratePost("loadout")]
|
||||
[EndpointName("SetPartyLoadout")]
|
||||
[EndpointSummary("Set the current party loadout")]
|
||||
[EndpointDescription("Set the current party loadout")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
public async Task<IResult> SetPartyLoadout([FromBody] PartyLoadout partyLoadout, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await this.partyService.SetPartyLoadout(partyLoadout, cancellationToken);
|
||||
return result ? Results.Ok() : Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>$(TargetFrameworkVersionWindows)</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<OutputType>Library</OutputType>
|
||||
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">win-x86</RuntimeIdentifier>
|
||||
<StripSymbols>true</StripSymbols>
|
||||
<InteropExports>true</InteropExports>
|
||||
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
|
||||
<EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
|
||||
|
||||
<PublishAot>true</PublishAot>
|
||||
<SelfContained>true</SelfContained>
|
||||
|
||||
<AspNetCoreHostingModel>OutOfProcess</AspNetCoreHostingModel>
|
||||
<UseAppHost>false</UseAppHost>
|
||||
<EnableIISSupport>false</EnableIISSupport>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
|
||||
<Platforms>x86</Platforms>
|
||||
|
||||
<!-- Prevent static web asset conflicts -->
|
||||
<StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
|
||||
<GenerateStaticWebAssetsManifest>false</GenerateStaticWebAssetsManifest>
|
||||
<ResolveStaticWebAssetsInputsDependsOn>RemoveDuplicateStaticWebAssets;$(ResolveStaticWebAssetsInputsDependsOn)</ResolveStaticWebAssetsInputsDependsOn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
|
||||
<DebugType>none</DebugType>
|
||||
<DebugSymbols>false</DebugSymbols>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Remove duplicate static web assets coming from referenced projects -->
|
||||
<Target Name="RemoveDuplicateStaticWebAssets" BeforeTargets="ResolveStaticWebAssetsInputs;GenerateStaticWebAssetsManifest">
|
||||
<ItemGroup>
|
||||
<!-- Remove all blazor webview static assets since this is a Web API project, not a Blazor Hybrid app -->
|
||||
<StaticWebAsset Remove="@(StaticWebAsset)"
|
||||
Condition="$([System.String]::new('%(RelativePath)').Contains('blazor.modules')) Or $([System.String]::new('%(RelativePath)').Contains('blazor.webview'))" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
<PropertyGroup>
|
||||
<!--Ignore trim warnings. NativeAOT support is still experimental so it should be tested with a Publish build generated from Daybreak build-->
|
||||
<NoWarn>IL2104</NoWarn>
|
||||
<NoWarn>IL3053</NoWarn>
|
||||
<!--MinHook.NET does not contain sources for .NET, only .Net Framework. This has no impact on the functionality of the project-->
|
||||
<NoWarn>NU1701</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Daybreak.Generators" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Configuration\appsettings.Debug.json" />
|
||||
<EmbeddedResource Include="Configuration\appsettings.json" />
|
||||
<EmbeddedResource Include="Configuration\appsettings.Release.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\Dependencies\GWCA\gwca.dll">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<Link>gwca.dll</Link>
|
||||
</None>
|
||||
<AdditionalFiles Include="..\Dependencies\GWCA\gwca.dll" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<RdXmlFile Include="rd.xml" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<PackageReference Include="MinHook.NET" />
|
||||
<PackageReference Include="Net.Sdk.Web.Extensions" />
|
||||
<PackageReference Include="Net.Sdk.Web.Extensions.SourceGenerators" />
|
||||
<PackageReference Include="PeNet" />
|
||||
<PackageReference Include="Serilog" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" />
|
||||
<PackageReference Include="Serilog.Settings.Configuration" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" />
|
||||
<PackageReference Include="Serilog.Sinks.File" />
|
||||
<PackageReference Include="Scalar.AspNetCore" />
|
||||
<PackageReference Include="SystemExtensions.NetStandard.Generators" PrivateAssets="all" />
|
||||
<PackageReference Include="ZLinq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Daybreak.Shared\Daybreak.Shared.csproj" />
|
||||
<ProjectReference Include="..\Daybreak.Generators\Daybreak.Generators.csproj"
|
||||
OutputItemType="Analyzer"
|
||||
ReferenceOutputAssembly="false"
|
||||
SkipGetTargetFrameworkProperties="true"
|
||||
UndefineProperties="RuntimeIdentifier;PublishAot;SelfContained;TargetFramework" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,258 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Extensions.Core;
|
||||
using System.Logging;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using Daybreak.API.Configuration;
|
||||
using Daybreak.API.Extensions;
|
||||
using Daybreak.API.Health;
|
||||
using Daybreak.API.Hosting;
|
||||
using Daybreak.API.Interop;
|
||||
using Daybreak.API.Logging;
|
||||
using Daybreak.API.Serialization;
|
||||
using Daybreak.API.Services;
|
||||
using Daybreak.API.Swagger;
|
||||
using Daybreak.API.WebSockets;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Net.Sdk.Web;
|
||||
|
||||
namespace Daybreak.API;
|
||||
|
||||
public class EntryPoint
|
||||
{
|
||||
private const int StartPort = 5080;
|
||||
private const string GwcaDllName = "gwca.dll";
|
||||
private static readonly TimeSpan InitializationTimeout = TimeSpan.FromSeconds(5);
|
||||
private static readonly CancellationTokenSource CancellationTokenSource = new();
|
||||
private static readonly object GwcaResolverLock = new();
|
||||
private static IntPtr gwcaHandle;
|
||||
private static bool gwcaResolverRegistered;
|
||||
|
||||
[UnmanagedCallersOnly(EntryPoint = "ThreadInit"), STAThread]
|
||||
[RequiresUnreferencedCode("The handler uses a static method that gets referenced, so there's no unreferenced code to worry about")]
|
||||
[RequiresDynamicCode("The handler uses a static method, so there's no dynamic code to worry about")]
|
||||
[SuppressMessage("Trimming", "IL2123:The use of 'RequiresUnreferencedCodeAttribute' on entry points is disallowed since the method will be called from outside the visible app.", Justification = "<Pending>")]
|
||||
[SuppressMessage("AOT", "IL3057:The use of 'RequiresDynamicCodeAttribute' on entry points is disallowed since the method will be called from outside the visible app.", Justification = "<Pending>")]
|
||||
public static int ThreadInit(IntPtr _, int __)
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ASPNETCORE_HOSTINGSTARTUPASSEMBLIES", null, EnvironmentVariableTarget.Process);
|
||||
Environment.SetEnvironmentVariable("ASPNETCORE_PREVENTHOSTINGSTARTUP", "true", EnvironmentVariableTarget.Process);
|
||||
#if DEBUG
|
||||
ConsoleExtensions.AllocateAnsiConsole();
|
||||
#endif
|
||||
var port = FindAvailablePort(StartPort);
|
||||
var app = CreateApplication(port);
|
||||
var runTask = Task.Run(() => StartServer(app), CancellationTokenSource.Token);
|
||||
var scopedLogger = app.Services.GetRequiredService<ILogger<EntryPoint>>().CreateScopedLogger();
|
||||
var healthCheck = app.Services.GetRequiredService<HealthCheckService>();
|
||||
var sw = Stopwatch.StartNew();
|
||||
var healthy = false;
|
||||
while (sw.Elapsed < InitializationTimeout)
|
||||
{
|
||||
var status = Task.Run(() => healthCheck.CheckHealthAsync()).Result;
|
||||
scopedLogger.LogWarning("HealthCheck status: {status} in {duration}. Report: {report}", status.Status, status.TotalDuration, string.Join("\n", status.Entries.Select(e => $"{e.Key}: {e.Value.Status}")));
|
||||
if (status.Status is HealthStatus.Healthy)
|
||||
{
|
||||
healthy = true;
|
||||
break;
|
||||
}
|
||||
else
|
||||
{
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
if (healthy)
|
||||
{
|
||||
scopedLogger.LogInformation("Daybreak API is healthy on port {port}. Initialization succeeded in {duration}", port, sw.Elapsed);
|
||||
return port;
|
||||
}
|
||||
else
|
||||
{
|
||||
scopedLogger.LogError("Daybreak API failed to initialize in {duration}", sw.Elapsed);
|
||||
CancellationTokenSource.Cancel();
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
[RequiresUnreferencedCode("The handler uses a static method that gets referenced, so there's no unreferenced code to worry about")]
|
||||
[RequiresDynamicCode("The handler uses a static method, so there's no dynamic code to worry about")]
|
||||
private static WebApplication CreateApplication(int port)
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder()
|
||||
.WithConfiguration()
|
||||
.WithHosting(port)
|
||||
.WithSwagger()
|
||||
.WithSerializationContext()
|
||||
.WithLogging()
|
||||
.WithDaybreakServices()
|
||||
.WithWebSocketRoutes()
|
||||
.WithRoutes()
|
||||
.WithHealthChecks();
|
||||
|
||||
// Add CORS policy - allow localhost and trusted community sites
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
{
|
||||
policy.SetIsOriginAllowed(origin =>
|
||||
{
|
||||
if (Uri.TryCreate(origin, UriKind.Absolute, out var uri))
|
||||
{
|
||||
// Always allow localhost
|
||||
if (uri.Host is "localhost" or "127.0.0.1")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Allow known Guild Wars community sites
|
||||
return uri.Host.EndsWith("guildwars.app", StringComparison.OrdinalIgnoreCase)
|
||||
|| uri.Host.EndsWith("gwmarket.net", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return false;
|
||||
})
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()
|
||||
.AllowCredentials();
|
||||
});
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseCors();
|
||||
app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(30) });
|
||||
|
||||
return app
|
||||
.UseHealthChecks()
|
||||
.UseLogging()
|
||||
.UseRoutes()
|
||||
.UseWebSocketRoutes()
|
||||
.UseSwaggerWithUI();
|
||||
|
||||
}
|
||||
|
||||
private static async Task StartServer(WebApplication app)
|
||||
{
|
||||
var scopedLogger = app.Services.GetRequiredService<ILogger<EntryPoint>>().CreateScopedLogger();
|
||||
try
|
||||
{
|
||||
scopedLogger.LogDebug("Initializing GWCA");
|
||||
PreloadNativeDependencies(scopedLogger);
|
||||
var result = GWCA.GW.Initialize();
|
||||
scopedLogger.LogDebug($"GWCA.GW.Initialize() returned: {result}");
|
||||
await app.RunAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
scopedLogger.LogCritical(ex, $"Encountered fatal error while starting API server {ex}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static int FindAvailablePort(int startPort)
|
||||
{
|
||||
var port = startPort;
|
||||
while (!IsPortAvailable(port))
|
||||
{
|
||||
port++;
|
||||
if (port > 65535 || port < 0)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
return port;
|
||||
}
|
||||
|
||||
private static bool IsPortAvailable(int port)
|
||||
{
|
||||
var ipProperties = IPGlobalProperties.GetIPGlobalProperties();
|
||||
var tcpListeners = ipProperties.GetActiveTcpListeners();
|
||||
if (tcpListeners.Any(endpoint => endpoint.Port == port))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var tcpConnections = ipProperties.GetActiveTcpConnections();
|
||||
if (tcpConnections.Any(conn => conn.LocalEndPoint.Port == port))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void PreloadNativeDependencies(ScopedLogger<EntryPoint> logger)
|
||||
{
|
||||
// First, check if gwca.dll is already loaded in the process (e.g. by GWToolbox).
|
||||
// If so, reuse it via a ref-count bump on the existing path rather than loading
|
||||
// a second copy from the Daybreak.API directory. Loading a second instance of
|
||||
// gwca.dll would result in duplicate native modules in the process, which can
|
||||
// lead to inconsistent state across mods that depend on it.
|
||||
string? daybreakApiDir = default;
|
||||
foreach (ProcessModule module in Process.GetCurrentProcess().Modules)
|
||||
{
|
||||
if (module.ModuleName?.Equals("gwca.dll", StringComparison.OrdinalIgnoreCase) is true)
|
||||
{
|
||||
var handle = NativeLibrary.Load(module.FileName);
|
||||
RegisterGwcaResolver(handle, module.FileName, logger);
|
||||
logger.LogDebug($"Reused already-loaded gwca.dll from {module.FileName}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (daybreakApiDir is null &&
|
||||
module.ModuleName?.Contains("Daybreak.API", StringComparison.OrdinalIgnoreCase) is true)
|
||||
{
|
||||
daybreakApiDir = Path.GetDirectoryName(module.FileName);
|
||||
}
|
||||
}
|
||||
|
||||
if (daybreakApiDir is null)
|
||||
{
|
||||
logger.LogError("Could not locate Daybreak.API module directory to preload gwca.dll");
|
||||
return;
|
||||
}
|
||||
|
||||
var gwcaPath = Path.Combine(daybreakApiDir, "gwca.dll");
|
||||
if (File.Exists(gwcaPath))
|
||||
{
|
||||
var handle = NativeLibrary.Load(gwcaPath);
|
||||
RegisterGwcaResolver(handle, gwcaPath, logger);
|
||||
logger.LogDebug($"Preloaded gwca.dll from {gwcaPath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogError($"gwca.dll not found at {gwcaPath}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void RegisterGwcaResolver(IntPtr handle, string path, ScopedLogger<EntryPoint> logger)
|
||||
{
|
||||
lock (GwcaResolverLock)
|
||||
{
|
||||
gwcaHandle = handle;
|
||||
if (gwcaResolverRegistered)
|
||||
{
|
||||
logger.LogDebug($"Updated gwca.dll resolver handle for {path}");
|
||||
return;
|
||||
}
|
||||
|
||||
NativeLibrary.SetDllImportResolver(typeof(GWCA).Assembly, ResolveNativeLibrary);
|
||||
gwcaResolverRegistered = true;
|
||||
logger.LogDebug($"Registered gwca.dll import resolver for {path}");
|
||||
}
|
||||
}
|
||||
|
||||
private static IntPtr ResolveNativeLibrary(string libraryName, Assembly assembly, DllImportSearchPath? searchPath)
|
||||
{
|
||||
if (string.Equals(libraryName, GwcaDllName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return gwcaHandle;
|
||||
}
|
||||
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Daybreak.API.Interop.GuildWars;
|
||||
using Daybreak.Shared.Models.Api;
|
||||
|
||||
namespace Daybreak.API.Extensions;
|
||||
|
||||
public static class AttributeContextExtensions
|
||||
{
|
||||
public static IEnumerable<AttributeEntry> GetAttributeEntryList(this AttributeStructArray54 attributes)
|
||||
{
|
||||
foreach (var attribute in attributes)
|
||||
{
|
||||
if (attribute.LevelBase > 0 && attribute.Level >= attribute.LevelBase)
|
||||
{
|
||||
yield return new AttributeEntry((uint)attribute.Id, attribute.LevelBase, attribute.Level);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Daybreak.API.Interop.GuildWars;
|
||||
|
||||
namespace Daybreak.API.Extensions;
|
||||
|
||||
public static class CharacterInformationExtensions
|
||||
{
|
||||
public unsafe static Profession GetPrimaryProfession(this CharacterInformation characterInformation)
|
||||
{
|
||||
return (Profession)((characterInformation.Props[2] >> 20) & 0xF);
|
||||
}
|
||||
|
||||
public unsafe static Profession GetSecondaryProfession(this CharacterInformation characterInformation)
|
||||
{
|
||||
return (Profession)((characterInformation.Props[7] >> 10) & 0xF);
|
||||
}
|
||||
|
||||
public unsafe static uint GetLevel(this CharacterInformation characterInformation)
|
||||
{
|
||||
return (characterInformation.Props[7] >> 4) & 0x3F;
|
||||
}
|
||||
|
||||
public unsafe static Campaign GetCampaign(this CharacterInformation characterInformation)
|
||||
{
|
||||
return (Campaign)(characterInformation.Props[7] & 0xF);
|
||||
}
|
||||
|
||||
public unsafe static MapID GetMapId(this CharacterInformation characterInformation)
|
||||
{
|
||||
return (MapID)((characterInformation.Props[0] >> 16) & 0xFFFF);
|
||||
}
|
||||
|
||||
public unsafe static bool IsPvP(this CharacterInformation characterInformation)
|
||||
{
|
||||
return ((characterInformation.Props[7] >> 9) & 0x1) == 0x1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Daybreak.API.Extensions;
|
||||
|
||||
public static class ConsoleExtensions
|
||||
{
|
||||
public static void AllocateAnsiConsole()
|
||||
{
|
||||
NativeMethods.AllocConsole();
|
||||
var handle = NativeMethods.GetStdHandle(NativeMethods.STD_OUTPUT_HANDLE);
|
||||
if (!NativeMethods.GetConsoleMode(handle, out var mode))
|
||||
{
|
||||
Console.WriteLine("Failed to get console mode");
|
||||
}
|
||||
|
||||
if (!NativeMethods.SetConsoleMode(handle, mode | NativeMethods.ENABLE_VIRTUAL_TERMINAL_PROCESSING | NativeMethods.ENABLE_PROCESSED_OUTPUT))
|
||||
{
|
||||
Console.WriteLine("Failed to enable virtual terminal processing");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using Daybreak.API.Interop;
|
||||
using Daybreak.API.Interop.GuildWars;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace Daybreak.API.Extensions;
|
||||
|
||||
public static unsafe class GameContextExtensions
|
||||
{
|
||||
public static bool TryGetPlayerId(
|
||||
this WrappedPointer<GameContext> gameContext,
|
||||
[NotNullWhen(true)] out uint? playerId)
|
||||
{
|
||||
playerId = 0;
|
||||
if (gameContext.IsNull ||
|
||||
gameContext.Pointer->World is null ||
|
||||
gameContext.Pointer->World->PlayerControlledChar is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
playerId = gameContext.Pointer->World->PlayerControlledChar->AgentId;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryGetBuildContext(
|
||||
this WrappedPointer<GameContext> gameContext,
|
||||
[NotNullWhen(true)] out GuildWarsArray<SkillbarData>? skillbars,
|
||||
[NotNullWhen(true)] out GuildWarsArray<PartyAttribute>? attributes,
|
||||
[NotNullWhen(true)] out GuildWarsArray<ProfessionState>? professions,
|
||||
[NotNullWhen(true)] out GuildWarsArray<uint>? unlockedSkills)
|
||||
{
|
||||
skillbars = default;
|
||||
attributes = default;
|
||||
professions = default;
|
||||
unlockedSkills = default;
|
||||
if (gameContext.IsNull ||
|
||||
gameContext.Pointer->World is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
skillbars = gameContext.Pointer->World->Skillbar.Value;
|
||||
attributes = gameContext.Pointer->World->Attributes.Value;
|
||||
professions = gameContext.Pointer->World->PartyProfessionStates;
|
||||
unlockedSkills = gameContext.Pointer->World->UnlockedCharacterSkills;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryGetPlayerParty(
|
||||
this WrappedPointer<GameContext> gameContext,
|
||||
[NotNullWhen(true)] out uint? partyId,
|
||||
[NotNullWhen(true)] out GuildWarsArray<PlayerPartyMember>? players,
|
||||
[NotNullWhen(true)] out GuildWarsArray<HeroPartyMember>? heroes,
|
||||
[NotNullWhen(true)] out GuildWarsArray<HenchmanPartyMember>? henchmen)
|
||||
{
|
||||
partyId = default;
|
||||
players = default;
|
||||
heroes = default;
|
||||
henchmen = default;
|
||||
if (gameContext.IsNull ||
|
||||
gameContext.Pointer->Party is 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var partyInfo = GWCA.GW.PartyMgr.GetPartyInfo(0);
|
||||
partyId = partyInfo->PartyId;
|
||||
players = partyInfo->Players;
|
||||
heroes = partyInfo->Heroes;
|
||||
henchmen = partyInfo->Henchmen;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryGetHeroFlags(
|
||||
this WrappedPointer<GameContext> gameContext,
|
||||
[NotNullWhen(true)]out GuildWarsArray<HeroFlag>? heroFlags)
|
||||
{
|
||||
heroFlags = default;
|
||||
if (gameContext.IsNull ||
|
||||
gameContext.Pointer->World is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
heroFlags = gameContext.Pointer->World->HeroFlags.Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryGetAccountContext(
|
||||
this WrappedPointer<GameContext> gameContext,
|
||||
out WrappedPointer<AccountContext> accountContext)
|
||||
{
|
||||
accountContext = default;
|
||||
if (gameContext.IsNull ||
|
||||
gameContext.Pointer->Account is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
accountContext = gameContext.Pointer->Account;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Daybreak.API.Interop.GuildWars;
|
||||
|
||||
namespace Daybreak.API.Extensions;
|
||||
|
||||
public static class TitleExtensions
|
||||
{
|
||||
public static bool IsPercentageBased(this Title title)
|
||||
{
|
||||
return (title.Props & 1) != 0;
|
||||
}
|
||||
|
||||
public static bool HasTiers(this Title title)
|
||||
{
|
||||
return (title.Props & 3) == 2;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Daybreak.API.Health;
|
||||
|
||||
public static class WebApplicationBuilderExtensions
|
||||
{
|
||||
public static WebApplicationBuilder WithHealthChecks(this WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.AddHealthChecks();
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Daybreak.API.Models;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using ZLinq;
|
||||
|
||||
namespace Daybreak.API.Health;
|
||||
|
||||
public static class WebApplicationExtensions
|
||||
{
|
||||
[RequiresUnreferencedCode("The handler uses a static method that gets referenced, so there's no unreferenced code to worry about")]
|
||||
[RequiresDynamicCode("The handler uses a static method, so there's no dynamic code to worry about")]
|
||||
public static WebApplication UseHealthChecks(this WebApplication app)
|
||||
{
|
||||
app.MapGet("/api/v1/rest/health", static async (HealthCheckService healthCheckService, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var report = await healthCheckService.CheckHealthAsync(cancellationToken);
|
||||
|
||||
var response = new HealthCheckResponse
|
||||
{
|
||||
ProcessId = Environment.ProcessId,
|
||||
Status = report.Status,
|
||||
TotalDuration = report.TotalDuration,
|
||||
Entries = report.Entries.ToDictionary(
|
||||
e => e.Key,
|
||||
e => new HealthCheckEntryResponse
|
||||
{
|
||||
Status = e.Value.Status,
|
||||
Description = e.Value.Description ?? string.Empty,
|
||||
Tags = [.. e.Value.Tags],
|
||||
Data = e.Value.Data
|
||||
.Where(kvp => kvp.Value is JsonElement je)
|
||||
.ToDictionary(kvp => kvp.Key,
|
||||
kvp => (JsonElement)kvp.Value)
|
||||
})
|
||||
};
|
||||
|
||||
return Results.Json(response);
|
||||
})
|
||||
.WithName("Health")
|
||||
.WithSummary("Service health")
|
||||
.WithDescription($"Current health status of the service. Returns a serialized object of type {nameof(HealthCheckResponse)}")
|
||||
.WithTags("Service")
|
||||
.Produces<HealthCheckResponse>(StatusCodes.Status200OK)
|
||||
.WithOpenApi();
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Daybreak.API.Hosting;
|
||||
|
||||
public static class WebApplicationBuilderExtensions
|
||||
{
|
||||
public static WebApplicationBuilder WithHosting(this WebApplicationBuilder builder, int port)
|
||||
{
|
||||
builder.WebHost.ConfigureKestrel(o => o.ListenAnyIP(port));
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#pragma warning disable CS0436 // Type conflicts with imported type
|
||||
using System.Extensions;
|
||||
|
||||
[assembly: GenerateFixedArray<uint>(Size = 4)]
|
||||
[assembly: GenerateFixedArray<char>(Size = 0x14)]
|
||||
[assembly: GenerateFixedArray<char>(Size = 0x40)]
|
||||
[assembly: GenerateFixedArray<byte>(Size = 0x18)]
|
||||
[assembly: GenerateFixedArray<char>(Size = 256)]
|
||||
[assembly: GenerateFixedArray<char>(Size = 32)]
|
||||
[assembly: GenerateFixedArray<char>(Size = 5)]
|
||||
[assembly: GenerateFixedArray<char>(Size = 8)]
|
||||
[assembly: GenerateFixedArray<byte>(Size = 56)]
|
||||
[assembly: GenerateFixedArray<uint>(Size = 17)]
|
||||
[assembly: GenerateFixedArray<uint>(Size = 12)]
|
||||
[assembly: GenerateFixedArray<uint>(Size = 13)]
|
||||
[assembly: GenerateFixedArray<uint>(Size = 8)]
|
||||
#pragma warning restore CS0436 // Type conflicts with imported type
|
||||
|
||||
namespace Daybreak.API.Interop;
|
||||
|
||||
public static class FixedArrays
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Daybreak.API.Interop;
|
||||
|
||||
[StructLayout(LayoutKind.Explicit, Pack = 1, Size = 0x1AC)]
|
||||
[GWCAEquivalent("Frame")]
|
||||
public readonly struct Frame
|
||||
{
|
||||
[FieldOffset(0x0008)]
|
||||
public readonly uint FrameLayout;
|
||||
|
||||
[FieldOffset(0x0018)]
|
||||
public readonly uint VisibilityFlags;
|
||||
|
||||
[FieldOffset(0x0020)]
|
||||
public readonly uint Type;
|
||||
|
||||
[FieldOffset(0x0024)]
|
||||
public readonly uint TemplateType;
|
||||
|
||||
[FieldOffset(0x00b8)]
|
||||
public readonly uint ChildOffsetId;
|
||||
|
||||
[FieldOffset(0x00bc)]
|
||||
public readonly uint FrameId;
|
||||
|
||||
[FieldOffset(0x018C)]
|
||||
public readonly uint FrameState;
|
||||
|
||||
public bool IsCreated => (this.FrameState & 0x4) != 0;
|
||||
public bool IsHidden => (this.FrameState & 0x200) != 0;
|
||||
public bool IsVisible => !this.IsHidden;
|
||||
public bool IsDIsabled => (this.FrameState & 0x10) != 0;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Daybreak.API.Interop;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
[GWCAEquivalent("HWND__")]
|
||||
public readonly struct GWHwnd
|
||||
{
|
||||
public readonly nint Handle;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Collections;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Daybreak.API.Interop.GuildWars;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
[GWCAEquivalent("Array")]
|
||||
public readonly unsafe struct GuildWarsArray<T> : IEnumerable<T>
|
||||
where T : unmanaged
|
||||
{
|
||||
public readonly T* Buffer;
|
||||
public readonly uint Capacity;
|
||||
public readonly uint Size;
|
||||
public readonly uint Param;
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound on a believable element count. Guild Wars' largest arrays hold at most a
|
||||
/// few thousand entries, so anything beyond this is uninitialised or misresolved memory
|
||||
/// rather than a real array.
|
||||
/// </summary>
|
||||
private const uint MaxPlausibleCapacity = 0x10000;
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors <c>GW::BaseArray::valid()</c> - the buffer must be null or aligned and the size
|
||||
/// must fit within the capacity - plus a bound on the capacity itself. Guild Wars leaves
|
||||
/// these fields transiently inconsistent while it (re)allocates an array, and a
|
||||
/// misresolved GWCA scan can point this struct at arbitrary bytes that still satisfy
|
||||
/// <c>size <= capacity</c>.
|
||||
/// </summary>
|
||||
public bool IsValid =>
|
||||
(this.Buffer is null || ((nuint)this.Buffer & 0x3) == 0) &&
|
||||
this.Size <= this.Capacity &&
|
||||
this.Capacity <= MaxPlausibleCapacity;
|
||||
|
||||
/// <summary>
|
||||
/// Number of elements that can safely be read. Zero whenever the array is not backed by
|
||||
/// a buffer, matching <c>GW::BaseArray::get()</c>, which returns null instead of
|
||||
/// dereferencing. Reading past this is an access violation, and because NativeAOT cannot
|
||||
/// throw <c>AccessViolationException</c> it fail-fasts and takes Guild Wars down with it.
|
||||
/// </summary>
|
||||
public uint Count => this.Buffer is not null && this.IsValid ? this.Size : 0;
|
||||
|
||||
public T this[int index]
|
||||
{
|
||||
get
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(index);
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual((uint)index, this.Count);
|
||||
return this.Buffer[index];
|
||||
}
|
||||
}
|
||||
|
||||
public Enumerator GetEnumerator() => new(this.Buffer, this.Count);
|
||||
|
||||
IEnumerator<T> IEnumerable<T>.GetEnumerator() => this.GetEnumerator();
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator();
|
||||
|
||||
public unsafe struct Enumerator : IEnumerator, IEnumerator<T>
|
||||
{
|
||||
private readonly T* buffer;
|
||||
private readonly uint size;
|
||||
private int index;
|
||||
|
||||
internal Enumerator(T* buffer, uint size)
|
||||
{
|
||||
this.buffer = buffer;
|
||||
this.size = size;
|
||||
this.index = -1;
|
||||
}
|
||||
|
||||
public bool MoveNext()
|
||||
{
|
||||
int next = this.index + 1;
|
||||
if (next >= this.size)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
this.index = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
public T Current => this.buffer[this.index];
|
||||
object IEnumerator.Current => this.Current;
|
||||
|
||||
public void Reset() => this.index = -1;
|
||||
public readonly void Dispose() { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Daybreak.API.Interop.GuildWars;
|
||||
|
||||
public readonly struct Uuid(
|
||||
uint timeLow,
|
||||
ushort timeMid,
|
||||
ushort timeHighAndVersion,
|
||||
byte clockSqHi,
|
||||
byte clockSqLow,
|
||||
byte node0,
|
||||
byte node1,
|
||||
byte node2,
|
||||
byte node3,
|
||||
byte node4,
|
||||
byte node5) : IEquatable<Uuid>
|
||||
{
|
||||
public const uint NodeLength = 6;
|
||||
|
||||
public readonly uint TimeLow = timeLow;
|
||||
public readonly ushort TimeMid = timeMid;
|
||||
public readonly ushort TimeHighAndVersion = timeHighAndVersion;
|
||||
public readonly byte ClockSqHiAndRes = clockSqHi;
|
||||
public readonly byte ClockSqLow = clockSqLow;
|
||||
public readonly byte Node0 = node0;
|
||||
public readonly byte Node1 = node1;
|
||||
public readonly byte Node2 = node2;
|
||||
public readonly byte Node3 = node3;
|
||||
public readonly byte Node4 = node4;
|
||||
public readonly byte Node5 = node5;
|
||||
|
||||
public bool Equals(Uuid other)
|
||||
{
|
||||
return this.TimeLow == other.TimeLow &&
|
||||
this.TimeMid == other.TimeMid &&
|
||||
this.TimeHighAndVersion == other.TimeHighAndVersion &&
|
||||
this.ClockSqHiAndRes == other.ClockSqHiAndRes &&
|
||||
this.ClockSqLow == other.ClockSqLow &&
|
||||
this.Node0 == other.Node0 &&
|
||||
this.Node1 == other.Node1 &&
|
||||
this.Node2 == other.Node2 &&
|
||||
this.Node3 == other.Node3 &&
|
||||
this.Node4 == other.Node4 &&
|
||||
this.Node5 == other.Node5;
|
||||
}
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
return obj is Uuid uuid && this.Equals(uuid);
|
||||
}
|
||||
|
||||
public static bool operator ==(Uuid left, Uuid right)
|
||||
{
|
||||
return left.Equals(right);
|
||||
}
|
||||
|
||||
public static bool operator !=(Uuid left, Uuid right)
|
||||
{
|
||||
return !(left == right);
|
||||
}
|
||||
|
||||
public static bool TryParse(string uuidString, out Uuid uuid)
|
||||
{
|
||||
if (uuidString.Length != 36)
|
||||
{
|
||||
uuid = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
var parts = uuidString.Split('-');
|
||||
if (parts.Length != 5)
|
||||
{
|
||||
uuid = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!uint.TryParse(parts[0], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var timeLow) ||
|
||||
!ushort.TryParse(parts[1], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var timeMid) ||
|
||||
!ushort.TryParse(parts[2], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var timeHighAndVersion) ||
|
||||
!byte.TryParse(parts[3].AsSpan(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var clockSqHi) ||
|
||||
!byte.TryParse(parts[3].AsSpan(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var clockSqLow) ||
|
||||
!byte.TryParse(parts[4].AsSpan(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var node0) ||
|
||||
!byte.TryParse(parts[4].AsSpan(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var node1) ||
|
||||
!byte.TryParse(parts[4].AsSpan(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var node2) ||
|
||||
!byte.TryParse(parts[4].AsSpan(6, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var node3) ||
|
||||
!byte.TryParse(parts[4].AsSpan(8, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var node4) ||
|
||||
!byte.TryParse(parts[4].AsSpan(10, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var node5))
|
||||
{
|
||||
uuid = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
uuid = new Uuid(timeLow, timeMid, timeHighAndVersion, clockSqHi, clockSqLow, node0, node1, node2, node3, node4, node5);
|
||||
return true;
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return HashCode.Combine<uint, ushort, ushort, byte, byte, byte[]>(this.TimeLow, this.TimeMid, this.TimeHighAndVersion, this.ClockSqHiAndRes, this.ClockSqLow, [this.Node0, this.Node1, this.Node2, this.Node3, this.Node4, this.Node5]);
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"{this.TimeLow:X8}-{this.TimeMid:X4}-{this.TimeHighAndVersion:X4}-{this.ClockSqHiAndRes:X2}{this.ClockSqLow:X2}-{this.Node0:X2}{this.Node1:X2}{this.Node2:X2}{this.Node3:X2}{this.Node4:X2}{this.Node5:X2}";
|
||||
}
|
||||
|
||||
public readonly static Uuid Zero = new(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
namespace Daybreak.API.Interop.GuildWars;
|
||||
|
||||
public readonly unsafe struct WrappedPointer<T>(T* pointer)
|
||||
where T : unmanaged
|
||||
{
|
||||
public readonly T* Pointer = pointer;
|
||||
|
||||
public bool IsNull => this.Pointer is null;
|
||||
|
||||
/// <summary>
|
||||
/// True when the pointer is non-null and plausibly dereferenceable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Guild Wars structures are 4-byte aligned on x86, so a misaligned address is never a
|
||||
/// real structure. When a GWCA pattern scan fails to resolve, it can hand back an
|
||||
/// address inside Gw.exe's code section (for example 0x0048CA72); reading through that
|
||||
/// yields nonsense field values and eventually an access violation. NativeAOT cannot
|
||||
/// throw <c>AccessViolationException</c>, so it fail-fasts and terminates Guild Wars.
|
||||
/// </remarks>
|
||||
public bool IsValid => this.Pointer is not null && ((nuint)this.Pointer & 0x3) == 0;
|
||||
|
||||
public static implicit operator WrappedPointer<T>(T* pointer) => new(pointer);
|
||||
|
||||
public static implicit operator T*(WrappedPointer<T> wrappedPointer) => wrappedPointer.Pointer;
|
||||
|
||||
public static bool operator ==(T* left, WrappedPointer<T> right) => left == right.Pointer;
|
||||
|
||||
public static bool operator !=(T* left, WrappedPointer<T> right) => left != right.Pointer;
|
||||
|
||||
public static bool operator ==(WrappedPointer<T> left, T* right) => left.Pointer == right;
|
||||
|
||||
public static bool operator !=(WrappedPointer<T> left, T* right) => left.Pointer != right;
|
||||
|
||||
public static bool operator ==(WrappedPointer<T> left, WrappedPointer<T> right) => left.Pointer == right.Pointer;
|
||||
|
||||
public static bool operator !=(WrappedPointer<T> left, WrappedPointer<T> right) => left.Pointer != right.Pointer;
|
||||
|
||||
public override int GetHashCode() => this.Pointer is null ? 0 : this.Pointer->GetHashCode();
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
if (obj is WrappedPointer<T> wrappedPointer)
|
||||
{
|
||||
return this.Pointer == wrappedPointer.Pointer;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Daybreak.API.Interop;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
[GWCAEquivalent("HookEntry")]
|
||||
public readonly struct HookEntry
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Daybreak.API.Interop.GuildWars;
|
||||
|
||||
namespace Daybreak.API.Interop;
|
||||
|
||||
public readonly struct ManagedFrame(
|
||||
WrappedPointer<Frame> frame,
|
||||
Action closeFrame) : IDisposable
|
||||
{
|
||||
private readonly Action closeFrame = closeFrame;
|
||||
|
||||
public static ManagedFrame Null => new(null, () => { });
|
||||
|
||||
public readonly WrappedPointer<Frame> Frame = frame;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this.closeFrame();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using MemoryPack;
|
||||
using System.Buffers;
|
||||
|
||||
namespace Daybreak.API.Interop;
|
||||
|
||||
public static class MemoryPackExtensions
|
||||
{
|
||||
/// <summary>Serialize <paramref name="value"/> into <paramref name="destination"/>.</summary>
|
||||
/// <returns>Number of bytes written.</returns>
|
||||
public static int SerializeToSpan<T>(T value, Span<byte> destination)
|
||||
{
|
||||
var dummy = DummyBufferWriter.Instance;
|
||||
|
||||
using var state = MemoryPackWriterOptionalStatePool.Rent(null);
|
||||
|
||||
var writer = new MemoryPackWriter<DummyBufferWriter>(ref dummy, destination, state);
|
||||
|
||||
writer.WriteValue(value);
|
||||
writer.Flush();
|
||||
|
||||
return writer.WrittenCount;
|
||||
}
|
||||
|
||||
private sealed class DummyBufferWriter : IBufferWriter<byte>
|
||||
{
|
||||
public static readonly DummyBufferWriter Instance = new();
|
||||
public void Advance(int count) { /* ignore – we never overflow destination */ }
|
||||
public Memory<byte> GetMemory(int sizeHint = 0) => throw new NotSupportedException();
|
||||
public Span<byte> GetSpan(int sizeHint = 0) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Daybreak.API.Interop;
|
||||
|
||||
public sealed class UnmanagedStruct<T> : IDisposable
|
||||
where T : struct
|
||||
{
|
||||
public nint Address { get; private set; }
|
||||
|
||||
public UnmanagedStruct(T value)
|
||||
{
|
||||
this.Address = Marshal.AllocHGlobal(Marshal.SizeOf<T>());
|
||||
Marshal.StructureToPtr(value, this.Address, false);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Marshal.DestroyStructure<T>(this.Address);
|
||||
Marshal.FreeHGlobal(this.Address);
|
||||
this.Address = 0x0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.CorrelationVector;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Net.Sdk.Web;
|
||||
using Net.Sdk.Web.Options;
|
||||
using Serilog.Context;
|
||||
|
||||
namespace Daybreak.API.Logging;
|
||||
|
||||
public sealed class LoggingEnrichmentMiddleware(IOptions<CorrelationVectorOptions> options)
|
||||
: IMiddleware
|
||||
{
|
||||
private readonly CorrelationVectorOptions options = options.Value;
|
||||
|
||||
public Task InvokeAsync(HttpContext context, RequestDelegate next)
|
||||
{
|
||||
if (!context.Request.Headers.TryGetValue(this.options.Header, out var value) ||
|
||||
value.FirstOrDefault() is not string cvString ||
|
||||
CorrelationVector.Parse(cvString) is not CorrelationVector cv)
|
||||
{
|
||||
cv = new CorrelationVector();
|
||||
}
|
||||
|
||||
cv = CorrelationVector.Extend(cv.ToString());
|
||||
context.SetCorrelationVector(cv);
|
||||
LogContext.PushProperty("CorrelationVector", cv);
|
||||
HeaderDictionaryExtensions.Append(context.Response.Headers, this.options.Header, context.GetCorrelationVector().ToString());
|
||||
return next(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Serilog.Settings.Configuration;
|
||||
using Serilog.Sinks.SystemConsole.Themes;
|
||||
using Serilog;
|
||||
using Net.Sdk.Web.Options;
|
||||
|
||||
namespace Daybreak.API.Logging;
|
||||
|
||||
public static class WebApplicationBuilderExtensions
|
||||
{
|
||||
public static WebApplicationBuilder WithLogging(this WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddSerilog(new LoggerConfiguration()
|
||||
.ReadFrom.Configuration(builder.Configuration, new ConfigurationReaderOptions
|
||||
{
|
||||
SectionName = "Logging"
|
||||
})
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Console(
|
||||
outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss}] {Level:u4}: [{SourceContext}] [{CorrelationVector}] {NewLine}{Message:lj}{NewLine}{Exception}",
|
||||
theme: AnsiConsoleTheme.Sixteen)
|
||||
.WriteTo.File(
|
||||
outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss}] {Level:u4}: [{SourceContext}] [{CorrelationVector}] {NewLine}{Message:lj}{NewLine}{Exception}",
|
||||
path: "Daybreak.API.log",
|
||||
rollingInterval: RollingInterval.Day,
|
||||
rollOnFileSizeLimit: true,
|
||||
fileSizeLimitBytes: 50 * 1024 * 1024,
|
||||
retainedFileCountLimit: 1,
|
||||
shared: true)
|
||||
.CreateLogger());
|
||||
builder.Services.AddScoped<LoggingEnrichmentMiddleware>();
|
||||
builder.Services.AddOptions<CorrelationVectorOptions>()
|
||||
.Bind(builder.Configuration.GetSection("Correlation"));
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Daybreak.API.Logging;
|
||||
|
||||
public static class WebApplicationExtensions
|
||||
{
|
||||
public static WebApplication UseLogging(this WebApplication app)
|
||||
{
|
||||
app.UseMiddleware<LoggingEnrichmentMiddleware>();
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Core.Extensions;
|
||||
|
||||
namespace Daybreak.API.Models;
|
||||
|
||||
public sealed class ByteConsumerEntry(
|
||||
Guid id, TimeSpan freq, Action<ReadOnlySpan<byte>> handler)
|
||||
{
|
||||
private readonly Action<ReadOnlySpan<byte>> handler = handler.ThrowIfNull();
|
||||
|
||||
private DateTime lastConsume = DateTime.MinValue;
|
||||
|
||||
public Guid Id { get; } = id;
|
||||
public TimeSpan Frequency { get; } = freq;
|
||||
|
||||
public void TryConsume(DateTime currentTime, ReadOnlySpan<byte> value)
|
||||
{
|
||||
if (currentTime - this.lastConsume >= this.Frequency)
|
||||
{
|
||||
this.handler(value);
|
||||
this.lastConsume = currentTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Core.Extensions;
|
||||
|
||||
namespace Daybreak.API.Models;
|
||||
|
||||
public sealed class CallbackRegistration(Guid uid, Action onDispose) : IDisposable
|
||||
{
|
||||
private readonly Guid uid = uid;
|
||||
private readonly Action onDispose = onDispose.ThrowIfNull();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this.onDispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using Daybreak.API.Interop.GuildWars;
|
||||
|
||||
namespace Daybreak.API.Models;
|
||||
|
||||
public struct DecodeStringContext(WrappedPointer<char> encodedString, TaskCompletionSource<string?> taskCompletionSource)
|
||||
{
|
||||
public WrappedPointer<char> EncodedString = encodedString;
|
||||
public TaskCompletionSource<string?> TaskCompletionSource = taskCompletionSource;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Daybreak.API.Models;
|
||||
|
||||
public sealed class HealthCheckEntryResponse
|
||||
{
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<HealthStatus>))]
|
||||
public required HealthStatus Status { get; set; }
|
||||
public required string Description { get; set; }
|
||||
public required Dictionary<string, JsonElement> Data { get; set; }
|
||||
public required List<string> Tags { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Daybreak.API.Models;
|
||||
|
||||
public sealed class HealthCheckResponse
|
||||
{
|
||||
public required int ProcessId { get; set; }
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<HealthStatus>))]
|
||||
public required HealthStatus Status { get; set; }
|
||||
public required TimeSpan TotalDuration { get; set; }
|
||||
public required Dictionary<string, HealthCheckEntryResponse> Entries { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Daybreak.API.Models;
|
||||
|
||||
public interface IWorkItem
|
||||
{
|
||||
CancellationToken CancellationToken { get; }
|
||||
void Execute();
|
||||
void Cancel();
|
||||
void Exception(Exception ex);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
namespace Daybreak.API.Models;
|
||||
|
||||
public sealed class WorkItem(
|
||||
Action action,
|
||||
TaskCompletionSource tcs,
|
||||
CancellationToken token) : IWorkItem
|
||||
{
|
||||
private readonly Action action = action;
|
||||
private readonly TaskCompletionSource tcs = tcs;
|
||||
|
||||
public CancellationToken CancellationToken { get; } = token;
|
||||
|
||||
public void Execute()
|
||||
{
|
||||
if (this.CancellationToken.IsCancellationRequested)
|
||||
{
|
||||
this.tcs.TrySetCanceled(this.CancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
this.action();
|
||||
this.tcs.TrySetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.tcs.TrySetException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void Cancel()
|
||||
{
|
||||
this.tcs.TrySetCanceled(this.CancellationToken);
|
||||
}
|
||||
|
||||
public void Exception(Exception ex)
|
||||
{
|
||||
this.tcs.TrySetException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class WorkItem<T>(
|
||||
Func<T> func,
|
||||
TaskCompletionSource<T> tcs,
|
||||
CancellationToken token) : IWorkItem
|
||||
{
|
||||
private readonly Func<T> func = func;
|
||||
private readonly TaskCompletionSource<T> tcs = tcs;
|
||||
|
||||
public CancellationToken CancellationToken { get; } = token;
|
||||
|
||||
public void Execute()
|
||||
{
|
||||
if (this.CancellationToken.IsCancellationRequested)
|
||||
{
|
||||
this.tcs.TrySetCanceled(this.CancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
this.tcs.TrySetResult(this.func());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.tcs.TrySetException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void Cancel()
|
||||
{
|
||||
this.tcs.TrySetCanceled(this.CancellationToken);
|
||||
}
|
||||
|
||||
public void Exception(Exception ex)
|
||||
{
|
||||
this.tcs.TrySetException(ex);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Daybreak.API;
|
||||
|
||||
internal unsafe static partial class NativeMethods
|
||||
{
|
||||
public const int STD_OUTPUT_HANDLE = -11;
|
||||
public const uint ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004;
|
||||
public const uint ENABLE_PROCESSED_OUTPUT = 0x0001;
|
||||
|
||||
public const int WM_KEYDOWN = 0x0100;
|
||||
public const int WM_KEYUP = 0x0101;
|
||||
public const int WM_CHAR = 0x0102;
|
||||
public const int VK_LEFT = 0x25;
|
||||
public const int VK_RIGHT = 0x27;
|
||||
|
||||
public const uint MEM_COMMIT = 0x1000;
|
||||
public const uint MEM_RESERVE = 0x2000;
|
||||
public const uint PAGE_EXECUTE_READWRITE = 0x40;
|
||||
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static partial bool AllocConsole();
|
||||
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
public static partial nint GetConsoleWindow();
|
||||
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
public static partial nint GetStdHandle(int nStdHandle);
|
||||
|
||||
[LibraryImport("kernel32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static partial bool GetConsoleMode(nint hConsoleHandle, out uint lpMode);
|
||||
|
||||
[LibraryImport("kernel32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static partial bool SetConsoleMode(nint hConsoleHandle, uint dwMode);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
public static partial nint SendMessageW(nint hWnd, int Msg, nint wParam, nuint lParam);
|
||||
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
public static partial nint VirtualAlloc(
|
||||
nint lpAddress, nuint dwSize,
|
||||
uint flAllocationType, uint flProtect);
|
||||
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static partial bool VirtualProtect(
|
||||
void* lpAddress, nuint dwSize,
|
||||
uint flNewProtect, out uint lpflOldProtect);
|
||||
|
||||
// Window enumeration
|
||||
public delegate bool EnumWindowsProc(nint hwnd, nint lParam);
|
||||
|
||||
[LibraryImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static partial bool EnumWindows(EnumWindowsProc lpEnumFunc, nint lParam);
|
||||
|
||||
[LibraryImport("user32.dll", SetLastError = true)]
|
||||
public static partial uint GetWindowThreadProcessId(nint hWnd, out uint lpdwProcessId);
|
||||
|
||||
[LibraryImport("user32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf8)]
|
||||
public static partial int GetClassNameA(nint hWnd, byte* lpClassName, int nMaxCount);
|
||||
|
||||
[LibraryImport("kernel32.dll")]
|
||||
public static partial uint GetCurrentProcessId();
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
// Required for LibraryImport source generator to work with blittable types like Vector2, Vector3
|
||||
[assembly: DisableRuntimeMarshalling]
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"Daybreak.API": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:51249;http://localhost:51250"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Daybreak.API.Models;
|
||||
using Daybreak.Shared.Models.Api;
|
||||
using Daybreak.Shared.Models.Guildwars;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Daybreak.API.Serialization;
|
||||
|
||||
[JsonSerializable(typeof(string))]
|
||||
[JsonSerializable(typeof(byte[]))]
|
||||
[JsonSerializable(typeof(int))]
|
||||
[JsonSerializable(typeof(uint))]
|
||||
[JsonSerializable(typeof(nuint))]
|
||||
[JsonSerializable(typeof(bool))]
|
||||
[JsonSerializable(typeof(Dictionary<string, string>))]
|
||||
[JsonSerializable(typeof(List<string>))]
|
||||
[JsonSerializable(typeof(JsonElement))]
|
||||
[JsonSerializable(typeof(HealthCheckResponse))]
|
||||
[JsonSerializable(typeof(HealthCheckEntryResponse))]
|
||||
[JsonSerializable(typeof(MainPlayerState))]
|
||||
[JsonSerializable(typeof(QuestLogInformation))]
|
||||
[JsonSerializable(typeof(QuestInformation))]
|
||||
[JsonSerializable(typeof(MainPlayerInformation))]
|
||||
[JsonSerializable(typeof(CharacterSelectInformation))]
|
||||
[JsonSerializable(typeof(CharacterSelectEntry))]
|
||||
[JsonSerializable(typeof(BuildEntry))]
|
||||
[JsonSerializable(typeof(List<BuildEntry>))]
|
||||
[JsonSerializable(typeof(PartyLoadoutEntry))]
|
||||
[JsonSerializable(typeof(List<PartyLoadout>))]
|
||||
[JsonSerializable(typeof(InstanceInfo))]
|
||||
[JsonSerializable(typeof(TitleInfo))]
|
||||
[JsonSerializable(typeof(LoginInfo))]
|
||||
[JsonSerializable(typeof(MainPlayerBuildContext))]
|
||||
[JsonSerializable(typeof(InventoryInformation))]
|
||||
[JsonSerializable(typeof(ItemProperty))]
|
||||
[JsonSourceGenerationOptions(UseStringEnumConverter = true)]
|
||||
public partial class ApiJsonSerializerContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Daybreak.API.Serialization;
|
||||
|
||||
public static class WebApplicationBuilderExtensions
|
||||
{
|
||||
public static WebApplicationBuilder WithSerializationContext(this WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
{
|
||||
options.SerializerOptions.TypeInfoResolverChain.Insert(0, new ApiJsonSerializerContext());
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
using Daybreak.API.Extensions;
|
||||
using Daybreak.API.Interop;
|
||||
using Daybreak.API.Interop.GuildWars;
|
||||
using Daybreak.API.Services.Interop;
|
||||
using Daybreak.Shared.Models.Api;
|
||||
using System.Core.Extensions;
|
||||
using System.Extensions;
|
||||
using System.Extensions.Core;
|
||||
using System.Runtime.InteropServices;
|
||||
using ZLinq;
|
||||
|
||||
namespace Daybreak.API.Services;
|
||||
|
||||
public sealed class CharacterSelectService(
|
||||
InstanceContextService instanceContextService,
|
||||
PlatformContextService platformContextService,
|
||||
UIContextService uiContextService,
|
||||
GameThreadService gameThreadService,
|
||||
GameContextService gameContextService,
|
||||
ILogger<CharacterSelectService> logger)
|
||||
{
|
||||
private readonly InstanceContextService instanceContextService = instanceContextService.ThrowIfNull();
|
||||
private readonly PlatformContextService platformContextService = platformContextService.ThrowIfNull();
|
||||
private readonly UIContextService uiContextService = uiContextService.ThrowIfNull();
|
||||
private readonly GameThreadService gameThreadService = gameThreadService.ThrowIfNull();
|
||||
private readonly GameContextService gameContextService = gameContextService.ThrowIfNull();
|
||||
private readonly ILogger<CharacterSelectService> logger = logger.ThrowIfNull();
|
||||
|
||||
public Task<CharacterSelectInformation?> GetCharacterSelectInformation(CancellationToken cancellationToken)
|
||||
{
|
||||
var scopedLogger = this.logger.CreateScopedLogger();
|
||||
return this.gameThreadService.QueueOnGameThread(() =>
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
var gameContext = this.gameContextService.GetGameContext();
|
||||
if (gameContext.IsNull ||
|
||||
gameContext.Pointer->World is null ||
|
||||
gameContext.Pointer->Character is null)
|
||||
{
|
||||
scopedLogger.LogError("Game context is not initialized");
|
||||
return default;
|
||||
}
|
||||
|
||||
var availableCharsContext = this.gameContextService.GetAvailableChars();
|
||||
if (!availableCharsContext.IsValid ||
|
||||
!availableCharsContext.Pointer->IsValid)
|
||||
{
|
||||
scopedLogger.LogError(
|
||||
"Available characters context is not initialized (array@0x{Array:X8} buffer=0x{Buffer:X8} capacity={Capacity} size={Size})",
|
||||
(nuint)availableCharsContext.Pointer,
|
||||
availableCharsContext.IsValid ? (nuint)availableCharsContext.Pointer->Buffer : 0,
|
||||
availableCharsContext.IsValid ? availableCharsContext.Pointer->Capacity : 0,
|
||||
availableCharsContext.IsValid ? availableCharsContext.Pointer->Size : 0);
|
||||
return default;
|
||||
}
|
||||
|
||||
var currentUuid = (*(Uuid*)gameContext.Pointer->Character->PlayerUuid).ToString();
|
||||
var availableChars = new List<CharacterSelectEntry>((int)availableCharsContext.Pointer->Count);
|
||||
foreach (var charContext in *availableCharsContext.Pointer)
|
||||
{
|
||||
var name = new string(charContext.Name);
|
||||
availableChars.Add(new CharacterSelectEntry(
|
||||
(*(Uuid*)charContext.Uuid).ToString(),
|
||||
name,
|
||||
(uint)charContext.GetPrimaryProfession(),
|
||||
(uint)charContext.GetSecondaryProfession(),
|
||||
(uint)charContext.GetCampaign(),
|
||||
(uint)charContext.GetMapId(),
|
||||
(uint)charContext.GetLevel(),
|
||||
charContext.IsPvP()));
|
||||
}
|
||||
|
||||
// TODO: Reforged sometimes returns Zero UUID even when in-game, need to investigate why
|
||||
if (currentUuid != Uuid.Zero.ToString())
|
||||
{
|
||||
var currentCharacter = availableChars.FirstOrDefault(c => c.Uuid == currentUuid);
|
||||
return new CharacterSelectInformation(currentCharacter, availableChars);
|
||||
}
|
||||
else
|
||||
{
|
||||
var currentCharacter = availableChars.FirstOrDefault();
|
||||
return new CharacterSelectInformation(currentCharacter, availableChars);
|
||||
}
|
||||
}
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> ChangeCharacterByName(string characterName, CancellationToken cancellationToken)
|
||||
{
|
||||
var scopedLogger = this.logger.CreateScopedLogger();
|
||||
if (await this.ValidateState(cancellationToken) is false)
|
||||
{
|
||||
scopedLogger.LogError("Cannot change character while in loading state");
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.TriggerLogOut(cancellationToken);
|
||||
|
||||
var selectResult = await this.WaitForCharSelectAndSelectCharacter(characterName, cancellationToken);
|
||||
if (!selectResult)
|
||||
{
|
||||
scopedLogger.LogError("Failed to select character {name}", characterName);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> ChangeCharacterByUuid(string uuid, CancellationToken cancellationToken)
|
||||
{
|
||||
var scopedLogger = this.logger.CreateScopedLogger();
|
||||
if (await this.ValidateState(cancellationToken) is false)
|
||||
{
|
||||
scopedLogger.LogError("Cannot change character while in loading state");
|
||||
return false;
|
||||
}
|
||||
|
||||
var desiredCharName = await this.GetCharNameByUuid(uuid, cancellationToken);
|
||||
if (desiredCharName is null)
|
||||
{
|
||||
scopedLogger.LogError("Failed to find character with UUID: {uuid}", uuid);
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.TriggerLogOut(cancellationToken);
|
||||
|
||||
var selectResult = await this.WaitForCharSelectAndSelectCharacter(desiredCharName, cancellationToken);
|
||||
if (!selectResult)
|
||||
{
|
||||
scopedLogger.LogError("Failed to select character {name} with uuid {uuid}", desiredCharName, uuid);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
private unsafe struct CharSelectButtonParam
|
||||
{
|
||||
public char* Name;
|
||||
public uint Play;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Explicit, Pack = 1)]
|
||||
private readonly unsafe struct CharSelectorContext
|
||||
{
|
||||
[FieldOffset(0x0000)]
|
||||
public readonly uint Vtable;
|
||||
|
||||
[FieldOffset(0x0004)]
|
||||
public readonly uint FrameId;
|
||||
|
||||
[FieldOffset(0x0008)]
|
||||
public readonly GuildWarsArray<WrappedPointer<CharSelectorChar>> Chars;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Explicit, Pack = 1)]
|
||||
private readonly struct CharSelectorChar
|
||||
{
|
||||
[FieldOffset(0x0020)]
|
||||
public readonly Array20Char Name;
|
||||
}
|
||||
|
||||
private async Task<bool> SelectCharacterToPlay(string characterName, bool play, CancellationToken cancellationToken)
|
||||
{
|
||||
var scopedLogger = this.logger.CreateScopedLogger();
|
||||
return await this.gameThreadService.QueueOnGameThread(() =>
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
if (!this.IsCharSelectReady())
|
||||
{
|
||||
scopedLogger.LogError("Character select is not ready");
|
||||
return false;
|
||||
}
|
||||
|
||||
var selectorFrame = this.GetCharSelectorFrame();
|
||||
if (selectorFrame is null)
|
||||
{
|
||||
scopedLogger.LogError("Character selector frame not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
var ctx = this.uiContextService.GetFrameContext<CharSelectorContext>(selectorFrame);
|
||||
if (ctx.IsNull ||
|
||||
(nuint)ctx.Pointer <= 0xFFFF)
|
||||
{
|
||||
scopedLogger.LogError("Character selector context not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
var panesFrame = this.uiContextService.GetChildFrame(selectorFrame, 0);
|
||||
if (panesFrame.IsNull)
|
||||
{
|
||||
scopedLogger.LogError("Character panes frame not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get current selected index
|
||||
uint selectedIdx = 0;
|
||||
this.uiContextService.SendFrameUIMessage(panesFrame, UIMessage.kFrameMessage_0x4a, null, &selectedIdx);
|
||||
|
||||
// Find target character index
|
||||
uint targetIdx = 0xFFFF;
|
||||
// Count (not Size) so a not-yet-allocated roster yields zero iterations
|
||||
// instead of indexing through a null buffer.
|
||||
var charCount = ctx.Pointer->Chars.Count;
|
||||
|
||||
for (uint i = 0; i < charCount; i++)
|
||||
{
|
||||
var c = ctx.Pointer->Chars.Buffer[i];
|
||||
if (c.IsNull)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var charNameSpan = c.Pointer->Name.AsSpan();
|
||||
var nullIdx = charNameSpan.IndexOf('\0');
|
||||
if (nullIdx <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var charName = new string(charNameSpan[..nullIdx]);
|
||||
if (!charName.Equals(characterName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
targetIdx = i;
|
||||
break;
|
||||
}
|
||||
|
||||
if (targetIdx >= charCount)
|
||||
{
|
||||
scopedLogger.LogError("Character {name} not found in character list", characterName);
|
||||
return false;
|
||||
}
|
||||
|
||||
var parentFrame = this.uiContextService.GetParentFrame(selectorFrame);
|
||||
if (parentFrame.IsNull)
|
||||
{
|
||||
scopedLogger.LogError("Parent frame not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Helper function to select a character at a specific index
|
||||
bool SelectChar(uint idx)
|
||||
{
|
||||
var charToSelect = ctx.Pointer->Chars.Buffer[idx];
|
||||
if (charToSelect.IsNull)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
fixed (char* namePtr = charToSelect.Pointer->Name.AsSpan())
|
||||
{
|
||||
var buttonParam = new CharSelectButtonParam
|
||||
{
|
||||
Name = namePtr,
|
||||
Play = 0
|
||||
};
|
||||
|
||||
var action = new kMouseAction
|
||||
{
|
||||
FrameId = selectorFrame->FrameId,
|
||||
ChildOffsetId = selectorFrame->ChildOffsetId,
|
||||
CurrentState = GWCA.GW.UI.UIPacket.ActionState.MouseClick,
|
||||
Wparam = (nint)(&buttonParam)
|
||||
};
|
||||
|
||||
if (!this.uiContextService.SendFrameUIMessage(parentFrame, UIMessage.kMouseClick2, &action))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
uint newSelectedIdx = 0;
|
||||
this.uiContextService.SendFrameUIMessage(panesFrame, UIMessage.kFrameMessage_0x4a, null, &newSelectedIdx);
|
||||
return newSelectedIdx == idx;
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to target character by selecting previous/next until we reach it.
|
||||
// Empty roster slots are represented by null entries in the character array, so
|
||||
// step over them instead of bailing out (otherwise navigation gets stuck at the
|
||||
// slot right before a gap and never reaches characters beyond it).
|
||||
while (targetIdx < selectedIdx)
|
||||
{
|
||||
if (selectedIdx == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var prevIdx = selectedIdx - 1;
|
||||
while (prevIdx > 0 && ctx.Pointer->Chars.Buffer[prevIdx].IsNull)
|
||||
{
|
||||
prevIdx--;
|
||||
}
|
||||
|
||||
if (ctx.Pointer->Chars.Buffer[prevIdx].IsNull)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (!SelectChar(prevIdx))
|
||||
{
|
||||
scopedLogger.LogError("Failed to select previous character");
|
||||
return false;
|
||||
}
|
||||
|
||||
selectedIdx = prevIdx;
|
||||
}
|
||||
|
||||
while (targetIdx > selectedIdx)
|
||||
{
|
||||
if (selectedIdx >= charCount - 1)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var nextIdx = selectedIdx + 1;
|
||||
while (nextIdx < charCount && ctx.Pointer->Chars.Buffer[nextIdx].IsNull)
|
||||
{
|
||||
nextIdx++;
|
||||
}
|
||||
|
||||
if (nextIdx >= charCount)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (!SelectChar(nextIdx))
|
||||
{
|
||||
scopedLogger.LogError("Failed to select next character");
|
||||
return false;
|
||||
}
|
||||
|
||||
selectedIdx = nextIdx;
|
||||
}
|
||||
|
||||
var chosen = selectedIdx == targetIdx;
|
||||
if (!chosen)
|
||||
{
|
||||
scopedLogger.LogError("Failed to select character {name}", characterName);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!play)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var playButton = this.uiContextService.GetFrameByLabel("Play");
|
||||
if (playButton.IsNull)
|
||||
{
|
||||
scopedLogger.LogError("Play button not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.uiContextService.ButtonClick(playButton);
|
||||
}
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<bool> WaitForCharSelectAndSelectCharacter(string characterName, CancellationToken cancellationToken)
|
||||
{
|
||||
var scopedLogger = this.logger.CreateScopedLogger();
|
||||
|
||||
// Wait for character select to be ready
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (await this.gameThreadService.QueueOnGameThread(this.IsCharSelectReady, cancellationToken))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await Task.Delay(100, cancellationToken);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
scopedLogger.LogError(e, "Error while waiting for character select to be ready");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// NOTE: Mirrors GWToolbox's GW::LoginMgr::SelectCharacterToPlay, which navigates the
|
||||
// character carousel purely by stepping prev/next over the selector context's character
|
||||
// array. It deliberately does NOT touch the in-game CharSortOrder preference: the
|
||||
// navigation relies on the selector context order matching the visible carousel order,
|
||||
// and forcing a re-sort here desyncs the two (the click-by-name steps then land on the
|
||||
// wrong index, so navigation only appears to work in one direction).
|
||||
return await this.SelectCharacterToPlay(characterName, play: true, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<string?> GetCharNameByUuid(string uuid, CancellationToken cancellationToken)
|
||||
{
|
||||
var scopedLogger = this.logger.CreateScopedLogger();
|
||||
return await this.gameThreadService.QueueOnGameThread(() =>
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
var gameContext = this.gameContextService.GetGameContext();
|
||||
if (gameContext.IsNull)
|
||||
{
|
||||
scopedLogger.LogError("Game context is not initialized");
|
||||
return default;
|
||||
}
|
||||
|
||||
var availableCharsContext = this.gameContextService.GetAvailableChars();
|
||||
if (!availableCharsContext.IsValid ||
|
||||
!availableCharsContext.Pointer->IsValid)
|
||||
{
|
||||
scopedLogger.LogError("Available characters context is not initialized");
|
||||
return default;
|
||||
}
|
||||
|
||||
foreach (var charContext in *availableCharsContext.Pointer)
|
||||
{
|
||||
if ((*(Uuid*)charContext.Uuid).ToString() != uuid)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = new string(charContext.Name);
|
||||
return name;
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task TriggerLogOut(CancellationToken cancellationToken)
|
||||
{
|
||||
await this.gameThreadService.QueueOnGameThread(() =>
|
||||
{
|
||||
var logoutMessage = new kLogout { Unknown = 0, CharacterSelect = 1 };
|
||||
unsafe
|
||||
{
|
||||
this.uiContextService.SendMessage(UIMessage.kLogout, (uint)&logoutMessage, 0);
|
||||
}
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<bool> ValidateState(CancellationToken cancellationToken)
|
||||
{
|
||||
return await this.gameThreadService.QueueOnGameThread(() =>
|
||||
{
|
||||
return this.instanceContextService.GetInstanceType() is not API.Interop.GuildWars.InstanceType.Loading;
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
private unsafe bool IsCharSelectReady()
|
||||
{
|
||||
// GW UI state must report char select (2). kCheckUIState fills the value passed via lParam.
|
||||
uint uiState = 10;
|
||||
this.uiContextService.SendMessage(UIMessage.kCheckUIState, 0, (nuint)(&uiState));
|
||||
|
||||
var selectorFrame = this.GetCharSelectorFrame();
|
||||
if (uiState != 2 ||
|
||||
selectorFrame is null ||
|
||||
!selectorFrame->IsVisible)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// The frame context must be fully populated (a real pointer) and belong to this frame.
|
||||
var ctx = this.uiContextService.GetFrameContext<CharSelectorContext>(selectorFrame);
|
||||
if (ctx.IsNull ||
|
||||
(nuint)ctx.Pointer <= 0xFFFF)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return ctx.Pointer->FrameId == selectorFrame->FrameId;
|
||||
}
|
||||
|
||||
private unsafe Frame* GetCharSelectorFrame()
|
||||
{
|
||||
var selectorFrame = this.uiContextService.GetFrameByLabel("Selector");
|
||||
if (selectorFrame.IsNull)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return selectorFrame.Pointer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using Daybreak.API.Interop;
|
||||
using Daybreak.API.Interop.GuildWars;
|
||||
using Daybreak.API.Services.Interop;
|
||||
using System.Core.Extensions;
|
||||
using System.Extensions;
|
||||
|
||||
namespace Daybreak.API.Services;
|
||||
|
||||
public sealed class ChatService(
|
||||
InstanceContextService instanceContextService,
|
||||
GameThreadService gameThreadService,
|
||||
UIContextService uiContextService)
|
||||
{
|
||||
private readonly SemaphoreSlim semaphoreSlim = new(1);
|
||||
private readonly InstanceContextService instanceContextService = instanceContextService.ThrowIfNull();
|
||||
private readonly GameThreadService gameThreadService = gameThreadService.ThrowIfNull();
|
||||
private readonly UIContextService uiContextService = uiContextService.ThrowIfNull();
|
||||
|
||||
/// <summary>
|
||||
/// Adds a message to the chat. It does not send the message, it only shows it to the player.
|
||||
/// </summary>
|
||||
public async ValueTask AddMessageAsync(string message, string? sender, Channel channel, CancellationToken cancellationToken)
|
||||
{
|
||||
using var ctx = await this.semaphoreSlim.Acquire(cancellationToken);
|
||||
await this.gameThreadService.QueueOnGameThread(() =>
|
||||
{
|
||||
if (this.instanceContextService.GetInstanceType() is InstanceType.Loading)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var encodedMessage = string.Create(
|
||||
3 + message.Length,
|
||||
message,
|
||||
static (span, msg) =>
|
||||
{
|
||||
span[0] = '\u0108';
|
||||
span[1] = '\u0107';
|
||||
msg.AsSpan().CopyTo(span[2..]);
|
||||
span[^1] = '\u0001';
|
||||
});
|
||||
|
||||
var encodedSender = sender is not null
|
||||
? string.Create(
|
||||
3 + sender.Length,
|
||||
sender,
|
||||
static (span, msg) =>
|
||||
{
|
||||
span[0] = '\u0108';
|
||||
span[1] = '\u0107';
|
||||
msg.AsSpan().CopyTo(span[2..]);
|
||||
span[^1] = '\u0001';
|
||||
})
|
||||
: default;
|
||||
|
||||
var encoded = encodedSender is null
|
||||
? encodedMessage
|
||||
: string.Create(
|
||||
encodedMessage.Length + encodedSender.Length + 6,
|
||||
(encodedMessage, encodedSender),
|
||||
static (span, state) =>
|
||||
{
|
||||
span[0] = '\u076b';
|
||||
span[1] = '\u010a';
|
||||
state.encodedSender.AsSpan().CopyTo(span[2..]);
|
||||
span[2 + state.encodedSender.Length] = '\u0001';
|
||||
span[3 + state.encodedSender.Length] = '\u010b';
|
||||
state.encodedMessage.AsSpan().CopyTo(span[(4 + state.encodedSender.Length)..]);
|
||||
span[^2] = '\u0001';
|
||||
});
|
||||
|
||||
unsafe
|
||||
{
|
||||
fixed (char* encodedPtr = encoded)
|
||||
{
|
||||
using var packet = new UnmanagedStruct<kPrintChatMessage>(new kPrintChatMessage { Channel = (GWCA.GW.Chat.Channel)channel, Message = (nint)encodedPtr });
|
||||
this.uiContextService.SendMessage(UIMessage.kWriteToChatLog, (nuint)packet.Address, 0x0);
|
||||
}
|
||||
}
|
||||
|
||||
}, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Daybreak.API.Interop;
|
||||
using Daybreak.API.Interop.GuildWars;
|
||||
|
||||
namespace Daybreak.API.Services.Interop;
|
||||
|
||||
public unsafe sealed class AgentContextService
|
||||
{
|
||||
public uint GetPlayerAgentId() => GWCA.GW.PlayerMgr.GetPlayerAgentId(0);
|
||||
|
||||
public Agent* GetAgentById(uint agentId) => GWCA.GW.Agents.GetAgentByID(agentId);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Daybreak.API.Interop;
|
||||
using Daybreak.API.Interop.GuildWars;
|
||||
|
||||
namespace Daybreak.API.Services.Interop;
|
||||
|
||||
public unsafe sealed class GameContextService
|
||||
{
|
||||
|
||||
public WrappedPointer<GameContext> GetGameContext() => GWCA.GW.GetGameContext();
|
||||
|
||||
public WrappedPointer<PreGameContext> GetPreGameContext() => GWCA.GW.GetPreGameContext();
|
||||
|
||||
public WrappedPointer<GuildWarsArray<CharacterInformation>> GetAvailableChars() => GWCA.GW.GetAvailableChars();
|
||||
|
||||
public bool IsMapLoaded() => GWCA.GW.Map.GetIsMapLoaded();
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using Daybreak.API.Interop;
|
||||
using Daybreak.API.Models;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security;
|
||||
|
||||
namespace Daybreak.API.Services.Interop;
|
||||
|
||||
public sealed class GameThreadService
|
||||
{
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate void GameThreadCallback();
|
||||
|
||||
// Prevent GC of delegates passed to native code
|
||||
private readonly ConcurrentDictionary<GameThreadCallback, bool> prevent_GC_callbacks = [];
|
||||
|
||||
public Task QueueOnGameThread(Action action, CancellationToken cancellationToken)
|
||||
{
|
||||
var taskCompletionSource = new TaskCompletionSource();
|
||||
var item = new WorkItem(action, taskCompletionSource, cancellationToken);
|
||||
|
||||
GameThreadCallback callback = null!;
|
||||
callback = () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (item.CancellationToken.IsCancellationRequested)
|
||||
{
|
||||
item.Cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
item.Execute();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
item.Exception(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.prevent_GC_callbacks.TryRemove(callback, out _);
|
||||
}
|
||||
};
|
||||
|
||||
// CRITICAL: Keep delegate alive until callback executes
|
||||
this.prevent_GC_callbacks[callback] = true;
|
||||
|
||||
var funcPtr = Marshal.GetFunctionPointerForDelegate(callback);
|
||||
GWCA.GW.GameThread.Enqueue(funcPtr, false);
|
||||
|
||||
return taskCompletionSource.Task;
|
||||
}
|
||||
|
||||
public Task<T> QueueOnGameThread<T>(Func<T> action, CancellationToken cancellationToken)
|
||||
{
|
||||
var taskCompletionSource = new TaskCompletionSource<T>();
|
||||
var item = new WorkItem<T>(action, taskCompletionSource, cancellationToken);
|
||||
|
||||
GameThreadCallback callback = null!;
|
||||
callback = () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (item.CancellationToken.IsCancellationRequested)
|
||||
{
|
||||
item.Cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
item.Execute();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
item.Exception(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Remove from prevent-GC set after execution
|
||||
this.prevent_GC_callbacks.TryRemove(callback, out _);
|
||||
}
|
||||
};
|
||||
|
||||
// CRITICAL: Keep delegate alive until callback executes
|
||||
this.prevent_GC_callbacks[callback] = true;
|
||||
|
||||
var funcPtr = Marshal.GetFunctionPointerForDelegate(callback);
|
||||
GWCA.GW.GameThread.Enqueue(funcPtr, false);
|
||||
|
||||
return taskCompletionSource.Task;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using Daybreak.API.Interop;
|
||||
using Daybreak.API.Interop.GuildWars;
|
||||
|
||||
namespace Daybreak.API.Services.Interop;
|
||||
|
||||
public sealed unsafe class InstanceContextService
|
||||
{
|
||||
public WrappedPointer<AreaInfo> GetAreaInfo() => GWCA.GW.Map.GetMapInfo(0);
|
||||
|
||||
public ServerRegion GetServerRegion() => (Daybreak.API.Interop.GuildWars.ServerRegion)GWCA.GW.Map.GetRegion();
|
||||
|
||||
public InstanceType GetInstanceType() => (Daybreak.API.Interop.GuildWars.InstanceType)GWCA.GW.Map.GetInstanceType();
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
using PeNet;
|
||||
using PeNet.Header.Pe;
|
||||
using System.Core.Extensions;
|
||||
using System.Diagnostics;
|
||||
using System.Extensions.Core;
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace Daybreak.API.Services.Interop;
|
||||
|
||||
public sealed unsafe class MemoryScanningService(
|
||||
ILogger<MemoryScanningService> logger)
|
||||
{
|
||||
private readonly ILogger<MemoryScanningService> logger = logger.ThrowIfNull();
|
||||
private readonly (nuint BaseAddress, ImageSectionHeader Section) textSection = GetSectionHeader(".text");
|
||||
private readonly (nuint BaseAddress, ImageSectionHeader Section) dataSection = GetSectionHeader(".rdata");
|
||||
|
||||
public uint GameTlsIndex { get; } = GetGameTlsIndex();
|
||||
|
||||
public nuint FunctionFromNearCall(nuint callInstructionAddress, bool checkValidPtr = true)
|
||||
{
|
||||
var scopedLogger = this.logger.CreateScopedLogger();
|
||||
if (callInstructionAddress is 0)
|
||||
{
|
||||
scopedLogger.LogError("Invalid call instruction address: 0x{address:X8}", callInstructionAddress);
|
||||
return 0;
|
||||
}
|
||||
|
||||
var opcode = Marshal.ReadByte((nint)callInstructionAddress);
|
||||
var functionAddress = opcode switch
|
||||
{
|
||||
0xE8 => (nuint)((nint)callInstructionAddress + 5 + Marshal.ReadInt32((nint)(callInstructionAddress + 1))),
|
||||
0xE9 => (nuint)((nint)callInstructionAddress + 5 + Marshal.ReadInt32((nint)(callInstructionAddress + 1))),
|
||||
0xEB => (nuint)((nint)callInstructionAddress + 2 + (sbyte)Marshal.ReadByte((nint)(callInstructionAddress + 1))),
|
||||
_ => (nuint)0
|
||||
};
|
||||
|
||||
if (checkValidPtr && functionAddress is 0)
|
||||
{
|
||||
scopedLogger.LogError("Invalid function address: 0x{address:X8}", functionAddress);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Recursively resolve nested JMPs
|
||||
var nestedCall = this.FunctionFromNearCall(functionAddress, checkValidPtr);
|
||||
if (nestedCall != 0)
|
||||
{
|
||||
scopedLogger.LogInformation("Resolved nested call to: 0x{address:X8}", nestedCall);
|
||||
return nestedCall;
|
||||
}
|
||||
|
||||
scopedLogger.LogInformation("Resolved function address: 0x{address:X8}", functionAddress);
|
||||
return functionAddress;
|
||||
}
|
||||
|
||||
public nuint FindAssertion(
|
||||
string assertionFile,
|
||||
string assertionMessage,
|
||||
uint lineNumber = 0,
|
||||
int offset = 0)
|
||||
{
|
||||
Span<byte> pattern =
|
||||
[
|
||||
0x68, 0,0,0,0, // push <line> / will be overwritten later
|
||||
0xBA, 0,0,0,0, // mov edx,<file> / will be overwritten later
|
||||
0xB9, 0,0,0,0 // mov ecx,<msg > / will be overwritten later
|
||||
];
|
||||
|
||||
const string maskAll = "xxxxxxxxxxxxxxx"; // 15 × 'x'
|
||||
|
||||
var msgBytes = Encoding.ASCII.GetBytes(assertionMessage + '\0');
|
||||
var msgMask = new string('x', msgBytes.Length);
|
||||
|
||||
var fileOrigBytes = Encoding.ASCII.GetBytes(assertionFile + '\0');
|
||||
var fileOrigMask = new string('x', fileOrigBytes.Length);
|
||||
|
||||
var (rdataBase, rdataHdr) = this.dataSection;
|
||||
var rdataStart = rdataBase + rdataHdr.VirtualAddress;
|
||||
var rdataEnd = rdataStart + rdataHdr.VirtualSize;
|
||||
|
||||
nuint msgSearchPos = rdataStart;
|
||||
while (true)
|
||||
{
|
||||
var msgPtr = FindInRange(msgBytes, msgMask, 0, msgSearchPos, rdataEnd);
|
||||
if (msgPtr is 0)
|
||||
{
|
||||
break; // no more messages → we are done
|
||||
}
|
||||
|
||||
msgSearchPos = msgPtr + 1; // next search starts just after the hit
|
||||
|
||||
// write ECX operand (little-endian) into the pattern
|
||||
BitConverter.TryWriteBytes(pattern.Slice(11, 4), (uint)msgPtr);
|
||||
|
||||
var fileSearchPos = rdataStart;
|
||||
while (true)
|
||||
{
|
||||
var filePtr = FindInRange(fileOrigBytes, fileOrigMask, 0, fileSearchPos, rdataEnd);
|
||||
|
||||
// fallbacks: lower-case and CamelCase, exactly like the C++
|
||||
if (filePtr == 0)
|
||||
{
|
||||
var lower = assertionFile.ToLowerInvariant();
|
||||
var lowerBytes = Encoding.ASCII.GetBytes(lower + '\0');
|
||||
filePtr = FindInRange(lowerBytes, new string('x', lowerBytes.Length), 0, fileSearchPos, rdataEnd);
|
||||
}
|
||||
|
||||
if (filePtr == 0)
|
||||
{
|
||||
var camel = ToCamelCase(assertionFile);
|
||||
var camelBytes = Encoding.ASCII.GetBytes(camel + '\0');
|
||||
filePtr = FindInRange(camelBytes, new string('x', camelBytes.Length), 0, fileSearchPos, rdataEnd);
|
||||
}
|
||||
|
||||
if (filePtr == 0)
|
||||
{
|
||||
break; // try next message
|
||||
}
|
||||
|
||||
fileSearchPos = filePtr + 1;
|
||||
|
||||
// If what we found is "…/file.hpp" (no drive letter) fix it
|
||||
if (Marshal.ReadByte((nint)(filePtr + 1)) != (byte)':')
|
||||
{
|
||||
// look ≤128 bytes backward for ':' and back-up one char
|
||||
nuint colon = FindInRange([(byte)':'], "x", -1, filePtr, filePtr - 128);
|
||||
if (colon == 0)
|
||||
{
|
||||
continue; // failed → try next file hit
|
||||
}
|
||||
|
||||
filePtr = colon;
|
||||
}
|
||||
|
||||
// write EDX operand
|
||||
BitConverter.TryWriteBytes(pattern.Slice(6, 4), (uint)filePtr);
|
||||
|
||||
nuint hit = 0;
|
||||
if (lineNumber != 0 && (lineNumber & 0xff) == lineNumber)
|
||||
{
|
||||
// PUSH imm8 variant – start matching at pattern[3]
|
||||
pattern[3] = 0x6A; // opcode
|
||||
pattern[4] = (byte)lineNumber;
|
||||
hit = FindAddressInternal(
|
||||
this.textSection,
|
||||
pattern[3..].ToArray(),
|
||||
maskAll[3..],
|
||||
offset);
|
||||
}
|
||||
|
||||
if (hit == 0 && lineNumber != 0)
|
||||
{
|
||||
// PUSH imm32 variant – whole pattern
|
||||
pattern[0] = 0x68;
|
||||
BitConverter.TryWriteBytes(pattern.Slice(1, 4), lineNumber);
|
||||
hit = FindAddressInternal(
|
||||
this.textSection,
|
||||
pattern.ToArray(),
|
||||
maskAll,
|
||||
offset);
|
||||
}
|
||||
|
||||
if (hit == 0 && lineNumber == 0)
|
||||
{
|
||||
// No line number – match only from MOV EDX onward
|
||||
hit = FindAddressInternal(
|
||||
this.textSection,
|
||||
pattern[5..].ToArray(),
|
||||
maskAll[5..],
|
||||
offset);
|
||||
}
|
||||
|
||||
if (hit != 0)
|
||||
{
|
||||
return hit; // found exactly what we wanted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0; // nothing matched
|
||||
}
|
||||
|
||||
public nuint FindAndResolveAddress(byte[] pattern, string mask, int offset = 0)
|
||||
{
|
||||
var scopedLogger = this.logger.CreateScopedLogger();
|
||||
var address = FindAddressInternal(this.textSection, pattern, mask, offset);
|
||||
if (address is 0)
|
||||
{
|
||||
scopedLogger.LogError("Failed to find address");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var ptr = (nuint)Marshal.ReadIntPtr((nint)address);
|
||||
return ptr;
|
||||
}
|
||||
|
||||
public nuint FindAddress(byte[] pattern, string mask, int offset = 0)
|
||||
{
|
||||
var address = FindAddressInternal(this.textSection, pattern, mask, offset);
|
||||
return address;
|
||||
}
|
||||
|
||||
public nuint FindAddress(byte[] pattern, string mask, int offset, nuint startAddress, nuint endAddress)
|
||||
{
|
||||
var address = FindAddressInternal(startAddress, endAddress, pattern, mask, offset);
|
||||
return address;
|
||||
}
|
||||
|
||||
public nuint ToFunctionStart(nuint callInstructionAddress, uint scanRange = 0x500)
|
||||
{
|
||||
if (callInstructionAddress == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// pattern: 55 8B EC – mask: "xxx"
|
||||
ReadOnlySpan<byte> prologue = [0x55, 0x8B, 0xEC];
|
||||
const string mask = "xxx";
|
||||
|
||||
var start = callInstructionAddress; // begin at the CALL itself
|
||||
var end = callInstructionAddress - scanRange; // scan backwards
|
||||
|
||||
return FindInRange(prologue, mask, 0, start, end);
|
||||
}
|
||||
|
||||
public nuint FindNthUseOfString(string str, uint nth, int offset = 0, bool useTextSection = true)
|
||||
{
|
||||
var scopedLogger = this.logger.CreateScopedLogger();
|
||||
var strBytes = Encoding.ASCII.GetBytes(str);
|
||||
var mask = new string('x', strBytes.Length);
|
||||
|
||||
var foundStr = FindAddressInternal(this.dataSection, strBytes, mask, 0);
|
||||
if (foundStr is 0)
|
||||
{
|
||||
scopedLogger.LogError("Failed to find string in .rdata section");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var firstNullChar = FindInRange([0x0], "x", 1, foundStr, foundStr - 0x64);
|
||||
if (firstNullChar is 0)
|
||||
{
|
||||
scopedLogger.LogError("Failed to find null terminator");
|
||||
return 0;
|
||||
}
|
||||
|
||||
return this.FindNthUseOfAddress(firstNullChar, nth, offset, useTextSection);
|
||||
}
|
||||
|
||||
public nuint FindUseOfString(string str, int offset = 0, bool useTextSection = true)
|
||||
{
|
||||
return this.FindNthUseOfString(str, 0, offset, useTextSection);
|
||||
}
|
||||
|
||||
public nuint FindNthUseOfWideString(string str, uint nth, int offset = 0, bool useTextSection = true)
|
||||
{
|
||||
var scopedLogger = this.logger.CreateScopedLogger();
|
||||
var strBytes = Encoding.Unicode.GetBytes(str);
|
||||
var mask = new string('x', strBytes.Length);
|
||||
|
||||
var foundStr = FindAddressInternal(this.dataSection, strBytes, mask, 0);
|
||||
if (foundStr is 0)
|
||||
{
|
||||
scopedLogger.LogError("Failed to find wide string in .rdata section");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var firstNullChar = FindInRange([0x0, 0x0], "xx", 2, foundStr, foundStr - 0x128);
|
||||
if (firstNullChar is 0)
|
||||
{
|
||||
scopedLogger.LogError("Failed to find null terminator for wide string");
|
||||
return 0;
|
||||
}
|
||||
|
||||
return this.FindNthUseOfAddress(firstNullChar, nth, offset, useTextSection);
|
||||
}
|
||||
|
||||
public nuint FindUseOfWideString(string str, int offset = 0, bool useTextSection = true)
|
||||
{
|
||||
return this.FindNthUseOfWideString(str, 0, offset, useTextSection);
|
||||
}
|
||||
|
||||
private nuint FindNthUseOfAddress(nuint address, uint nth, int offset, bool useTextSection)
|
||||
{
|
||||
var (BaseAddress, Section) = useTextSection ? this.textSection : this.dataSection;
|
||||
var addressBytes = BitConverter.GetBytes((uint)address);
|
||||
var mask = "xxxx";
|
||||
|
||||
var currentAddress = BaseAddress + Section.VirtualAddress;
|
||||
var endAddress = currentAddress + Section.VirtualSize;
|
||||
|
||||
uint foundCount = 0;
|
||||
while (currentAddress < endAddress)
|
||||
{
|
||||
var found = FindAddressInternal(currentAddress, endAddress, addressBytes, mask, 0);
|
||||
if (found is 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (foundCount == nth)
|
||||
{
|
||||
return (nuint)((nint)found + offset);
|
||||
}
|
||||
|
||||
foundCount++;
|
||||
currentAddress = found + 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static bool IsValidPtr(nuint address, (nuint BaseAddress, ImageSectionHeader Section) section)
|
||||
{
|
||||
return address > 0 &&
|
||||
(ulong)address > section.Section.ImageBaseAddress &&
|
||||
(ulong)address < (section.Section.ImageBaseAddress + section.Section.VirtualSize);
|
||||
}
|
||||
|
||||
private static unsafe nuint FindInRange(
|
||||
ReadOnlySpan<byte> pattern,
|
||||
string mask,
|
||||
int offset,
|
||||
nuint start,
|
||||
nuint end)
|
||||
{
|
||||
bool forward = start < end;
|
||||
int patLength = pattern.Length;
|
||||
|
||||
if (forward)
|
||||
{
|
||||
end -= (uint)patLength; // forward scan ⇒ clamp tail
|
||||
}
|
||||
|
||||
var cur = start;
|
||||
while (forward ? cur <= end : cur >= end)
|
||||
{
|
||||
if (Marshal.ReadByte((nint)cur) == pattern[0])
|
||||
{
|
||||
var matched = true;
|
||||
for (int i = 1; i < patLength && matched; ++i)
|
||||
{
|
||||
if (mask[i] == 'x' &&
|
||||
Marshal.ReadByte((nint)(cur + (uint)i)) != pattern[i])
|
||||
{
|
||||
matched = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (matched)
|
||||
{
|
||||
return (nuint)((long)cur + offset);
|
||||
}
|
||||
}
|
||||
|
||||
cur = forward ? cur + 1 : cur - 1;
|
||||
if (!forward && cur == 0) // prevent unsigned wrap-around
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static string ToCamelCase(string text)
|
||||
=> CultureInfo.InvariantCulture.TextInfo.ToTitleCase(text.ToLowerInvariant());
|
||||
|
||||
private static nuint FindAddressInternal((nuint BaseAddress, ImageSectionHeader Section) section, byte[] pattern, string mask, int offset = 0)
|
||||
{
|
||||
var textStart = section.BaseAddress + section.Section.VirtualAddress;
|
||||
var textEnd = textStart + section.Section.VirtualSize - (uint)pattern.Length;
|
||||
return FindAddressInternal(textStart, textEnd, pattern, mask, offset);
|
||||
}
|
||||
|
||||
private static nuint FindAddressInternal(nuint startAddr, nuint endAddr, byte[] pattern, string mask, int offset = 0)
|
||||
{
|
||||
for (var cur = startAddr; cur <= endAddr; ++cur)
|
||||
{
|
||||
if (Marshal.ReadByte((nint)cur) != pattern[0])
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var match = true;
|
||||
for (var i = 1; i < pattern.Length && match; ++i)
|
||||
{
|
||||
if (mask[i] is 'x' && Marshal.ReadByte((nint)(cur + (uint)i)) != pattern[i])
|
||||
{
|
||||
match = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (match)
|
||||
{
|
||||
return (nuint)((nint)cur + offset);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static (nuint, ImageSectionHeader) GetSectionHeader(string headerName)
|
||||
{
|
||||
var m = Process.GetCurrentProcess().MainModule ?? throw new InvalidOperationException("Failed to initialize memory scanner. Failed to find main module");
|
||||
var baseAddr = (nuint)m.BaseAddress;
|
||||
var pe = new PeFile(m.FileName);
|
||||
var textHdr = pe.ImageSectionHeaders?.FirstOrDefault(h => h.Name == headerName);
|
||||
return textHdr is null
|
||||
? throw new InvalidOperationException($"Failed to initialize memory scanner. Failed to find {headerName} section")
|
||||
: ((nuint, ImageSectionHeader))(baseAddr, textHdr);
|
||||
}
|
||||
|
||||
private static uint GetGameTlsIndex()
|
||||
{
|
||||
var m = Process.GetCurrentProcess().MainModule ?? throw new InvalidOperationException("Failed to get TLS index. Failed to find main module");
|
||||
var baseAddr = (nint)m.BaseAddress;
|
||||
var pe = new PeFile(m.FileName);
|
||||
|
||||
// Get the TLS data directory entry (index 9 = IMAGE_DIRECTORY_ENTRY_TLS)
|
||||
var tlsDirectory = pe.ImageNtHeaders?.OptionalHeader.DataDirectory[9];
|
||||
if (tlsDirectory is null || tlsDirectory.VirtualAddress == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to get TLS index. No TLS directory found");
|
||||
}
|
||||
|
||||
// Calculate the address of the TLS directory in memory
|
||||
var tlsDirectoryAddress = baseAddr + (nint)tlsDirectory.VirtualAddress;
|
||||
|
||||
// Read the IMAGE_TLS_DIRECTORY structure
|
||||
// AddressOfIndex is at offset 8 (after StartAddressOfRawData and EndAddressOfRawData)
|
||||
var addressOfIndexPtr = Marshal.ReadIntPtr(tlsDirectoryAddress + 8);
|
||||
|
||||
// Read the TLS index from the location pointed to by AddressOfIndex
|
||||
var tlsIndex = (uint)Marshal.ReadInt32(addressOfIndexPtr);
|
||||
|
||||
return tlsIndex;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using Daybreak.API.Interop;
|
||||
using static Daybreak.API.Interop.GWCA.GW.Constants;
|
||||
|
||||
namespace Daybreak.API.Services.Interop;
|
||||
|
||||
public sealed class PartyContextService
|
||||
{
|
||||
public bool AddHero(uint heroId) => GWCA.GW.PartyMgr.AddHero((HeroID)heroId);
|
||||
|
||||
public bool KickHero(uint heroId) => GWCA.GW.PartyMgr.KickHero((HeroID)heroId);
|
||||
|
||||
public bool KickAllHeroes() => GWCA.GW.PartyMgr.KickAllHeroes();
|
||||
|
||||
public bool LeaveParty() => GWCA.GW.PartyMgr.LeaveParty();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Daybreak.API.Interop;
|
||||
|
||||
namespace Daybreak.API.Services.Interop;
|
||||
|
||||
public sealed unsafe class PlatformContextService
|
||||
{
|
||||
public nint GetWindowHandle()
|
||||
{
|
||||
var hwnd = GWCA.GW.MemoryMgr.GetGWWindowHandle();
|
||||
// HWND is defined as HWND__* in Windows - the pointer value itself IS the handle,
|
||||
// not a pointer to a struct containing the handle. Cast directly to nint.
|
||||
return (nint)hwnd;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Daybreak.API.Interop;
|
||||
using Daybreak.API.Interop.GuildWars;
|
||||
|
||||
namespace Daybreak.API.Services.Interop;
|
||||
|
||||
public sealed class PreferencesService()
|
||||
{
|
||||
public uint? GetEnumPreference(EnumPreference pref) => GWCA.GW.UI.GetPreference((GWCA.GW.UI.EnumPreference)pref);
|
||||
|
||||
public bool SetEnumPreference(EnumPreference pref, uint value) => GWCA.GW.UI.SetPreference((GWCA.GW.UI.EnumPreference)pref, value);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using Daybreak.API.Interop;
|
||||
using Daybreak.API.Interop.GuildWars;
|
||||
|
||||
namespace Daybreak.API.Services.Interop;
|
||||
|
||||
public sealed class SkillbarContextService
|
||||
{
|
||||
public unsafe void LoadBuild(uint agentId, SkillTemplate* template) => GWCA.GW.SkillbarMgr.LoadSkillTemplate(agentId, template);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using Daybreak.API.Interop;
|
||||
using Daybreak.API.Interop.GuildWars;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Core.Extensions;
|
||||
using System.Extensions.Core;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security;
|
||||
|
||||
namespace Daybreak.API.Services.Interop;
|
||||
|
||||
public sealed class UIContextService(ILogger<UIContextService> logger)
|
||||
{
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private unsafe delegate void AsyncDecodeStrCallback(void* param, ushort* decodedString);
|
||||
|
||||
// Prevent GC of delegates passed to native code
|
||||
private readonly ConcurrentDictionary<AsyncDecodeStrCallback, bool> prevent_GC_callbacks = [];
|
||||
|
||||
private readonly ILogger<UIContextService> logger = logger.ThrowIfNull();
|
||||
|
||||
public unsafe WrappedPointer<T> GetFrameContext<T>(WrappedPointer<Frame> frame)
|
||||
where T : unmanaged
|
||||
{
|
||||
return (T*)GWCA.GW.UI.GetFrameContext(frame.Pointer);
|
||||
}
|
||||
|
||||
public unsafe WrappedPointer<Frame> GetChildFrame(WrappedPointer<Frame> parent, uint childOffset)
|
||||
{
|
||||
var scopedLogger = this.logger.CreateScopedLogger();
|
||||
if (parent.IsNull)
|
||||
{
|
||||
scopedLogger.LogError("Parent frame is null");
|
||||
return null;
|
||||
}
|
||||
|
||||
return GWCA.GW.UI.GetChildFrame(parent, childOffset);
|
||||
}
|
||||
|
||||
public WrappedPointer<Frame> GetButtonActionFrame()
|
||||
{
|
||||
return this.GetChildFrame(this.GetFrameByLabel("Game"), 6);
|
||||
}
|
||||
|
||||
public unsafe bool SendFrameUIMessage(WrappedPointer<Frame> frame, UIMessage messageId, void* arg1, void* arg2 = null)
|
||||
{
|
||||
var scopedLogger = this.logger.CreateScopedLogger();
|
||||
if (frame.IsNull)
|
||||
{
|
||||
scopedLogger.LogError("Frame is null");
|
||||
return false;
|
||||
}
|
||||
|
||||
return GWCA.GW.UI.SendFrameUIMessage(frame, (GWCA.GW.UI.UIMessage)messageId, arg1, (nint)arg2);
|
||||
}
|
||||
|
||||
public unsafe bool SetFrameDisabled(WrappedPointer<Frame> frame, bool disabled) => GWCA.GW.UI.SetFrameDisabled(frame, disabled);
|
||||
|
||||
public unsafe bool SetFrameVisible(WrappedPointer<Frame> frame, bool visible) => GWCA.GW.UI.SetFrameVisible(frame, visible);
|
||||
|
||||
public unsafe bool KeyDown(GWCA.GW.UI.ControlAction action, WrappedPointer<Frame> frame) => GWCA.GW.UI.Keydown(action, frame);
|
||||
|
||||
public unsafe bool KeyUp(GWCA.GW.UI.ControlAction action, WrappedPointer<Frame> frame) => GWCA.GW.UI.Keyup(action, frame);
|
||||
|
||||
public unsafe WrappedPointer<Frame> GetFrameByLabel(string label)
|
||||
{
|
||||
fixed (char* labelPtr = label)
|
||||
{
|
||||
return GWCA.GW.UI.GetFrameByLabel((ushort*)labelPtr);
|
||||
}
|
||||
}
|
||||
|
||||
public unsafe WrappedPointer<Frame> GetFrameById(uint frameId) => GWCA.GW.UI.GetFrameById(frameId);
|
||||
|
||||
public unsafe WrappedPointer<Frame> GetParentFrame(WrappedPointer<Frame> frame)
|
||||
{
|
||||
if (frame.IsNull)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return GWCA.GW.UI.GetParentFrame(frame);
|
||||
}
|
||||
|
||||
public unsafe bool ButtonClick(WrappedPointer<Frame> frame)
|
||||
{
|
||||
if (frame.IsNull)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return GWCA.GW.UI.ButtonClick(frame);
|
||||
}
|
||||
|
||||
public unsafe void SendMessage(UIMessage message, nuint wParam, nuint lParam) => GWCA.GW.UI.SendUIMessage((GWCA.GW.UI.UIMessage)message, (void*)wParam, (nint)lParam);
|
||||
|
||||
public unsafe Task<string> AsyncDecodeStringAsync(ushort* encodedString, GWCA.GW.Constants.Language language = GWCA.GW.Constants.Language.Unknown, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var taskCompletionSource = new TaskCompletionSource<string>();
|
||||
cancellationToken.Register(() => taskCompletionSource.TrySetCanceled(cancellationToken));
|
||||
void callback(void* param, ushort* decodedString)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
taskCompletionSource.TrySetCanceled(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert the decoded wide string to a managed string
|
||||
var result = decodedString != null
|
||||
? new string((char*)decodedString)
|
||||
: string.Empty;
|
||||
|
||||
taskCompletionSource.TrySetResult(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
taskCompletionSource.TrySetException(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.prevent_GC_callbacks.TryRemove(callback, out _);
|
||||
}
|
||||
}
|
||||
|
||||
// CRITICAL: Keep delegate alive until callback executes
|
||||
this.prevent_GC_callbacks[callback] = true;
|
||||
|
||||
var funcPtr = Marshal.GetFunctionPointerForDelegate(callback);
|
||||
// GWCA's AsyncDecodeStr handles language switching internally (sets language, decodes, restores)
|
||||
GWCA.GW.UI.AsyncDecodeStr(encodedString, funcPtr, 0, language);
|
||||
|
||||
return taskCompletionSource.Task;
|
||||
}
|
||||
|
||||
public unsafe Task<string> AsyncDecodeStringAsync(string encodedString, GWCA.GW.Constants.Language language = GWCA.GW.Constants.Language.Unknown, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Pin the string and convert to ushort*
|
||||
fixed (char* encodedPtr = encodedString)
|
||||
{
|
||||
return this.AsyncDecodeStringAsync((ushort*)encodedPtr, language, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using Daybreak.API.Interop;
|
||||
using Daybreak.API.Interop.GuildWars;
|
||||
using Daybreak.API.Services.Interop;
|
||||
using Daybreak.Shared.Models.Api;
|
||||
using Daybreak.Shared.Models.Guildwars;
|
||||
using System.Extensions.Core;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
|
||||
namespace Daybreak.API.Services;
|
||||
|
||||
public sealed class InventoryService(
|
||||
GameContextService gameContextService,
|
||||
GameThreadService gameThreadService,
|
||||
UIService uIService,
|
||||
ILogger<InventoryService> logger)
|
||||
{
|
||||
private readonly GameContextService gameContextService = gameContextService;
|
||||
private readonly GameThreadService gameThreadService = gameThreadService;
|
||||
private readonly UIService uIService = uIService;
|
||||
private readonly ILogger<InventoryService> logger = logger;
|
||||
|
||||
public async Task<InventoryInformation?> GetInventoryInformation(CancellationToken cancellationToken)
|
||||
{
|
||||
var scopedLogger = this.logger.CreateScopedLogger();
|
||||
var itemTuples = await this.gameThreadService.QueueOnGameThread(() =>
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
var gameContext = this.gameContextService.GetGameContext();
|
||||
if (gameContext.IsNull)
|
||||
{
|
||||
scopedLogger.LogError("Game context is null");
|
||||
return default;
|
||||
}
|
||||
|
||||
var inventory = gameContext.Pointer->Items->Inventory;
|
||||
if (inventory is null)
|
||||
{
|
||||
scopedLogger.LogError("Inventory is null");
|
||||
return default;
|
||||
}
|
||||
|
||||
var itemTuples = new List<(GWCA.GW.Constants.BagType Type, List<(uint ModelId, string EncodedCompleteName, string EncodedSingleName, string EncodedName, bool Inscribable, int Quantity, uint[] Modifiers, ItemType ItemType, uint Interaction, uint ModelFileId, Byte DyeTint, ushort Value)>)>();
|
||||
for (var i = 0; i < 23; i++)
|
||||
{
|
||||
// Use GWCA helper to fetch bag pointers by index — safer than reading an inline array directly.
|
||||
var bag = GWCA.GW.Items.GetBagByIndex((uint)i);
|
||||
if (bag is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var retBag = new List<(uint ModelId, string EncodedCompleteName, string EncodedSingleName, string EncodedName, bool Inscribable, int Quantity, uint[] Modifiers, ItemType ItemType, uint Interaction, uint ModelFileId, Byte DyeTint, ushort Value)>();
|
||||
itemTuples.Add((bag->BagType, retBag));
|
||||
if (bag->ItemsCount is 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var itemPtr in bag->Items.Value)
|
||||
{
|
||||
if (itemPtr is 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var item = (ItemStruct*)itemPtr;
|
||||
var singleItemName = new string((char*)item->SingleItemName);
|
||||
var nameEncoded = new string((char*)item->NameEnc);
|
||||
var completeNameEncoded = new string((char*)item->CompleteNameEnc);
|
||||
var modifiers = new uint[item->ModStructSize];
|
||||
for (var j = 0; j < item->ModStructSize; j++)
|
||||
{
|
||||
modifiers[j] = item->ModStruct[j].Mod;
|
||||
}
|
||||
|
||||
retBag.Add((item->ModelId, completeNameEncoded, singleItemName, nameEncoded, (item->Interaction & 0x08000000) != 0, item->Quantity, modifiers, (ItemType)item->Type, item->Interaction, item->ModelFileId, item->Dye.DyeTint, item->Value));
|
||||
}
|
||||
}
|
||||
|
||||
return itemTuples;
|
||||
}
|
||||
}, cancellationToken);
|
||||
|
||||
if (itemTuples is null)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
// Decode strings sequentially to avoid race conditions with the game's TextParser language field.
|
||||
// Parallel decoding causes crashes because multiple operations race to set/restore the language.
|
||||
var decodedItemTuples = new List<(GWCA.GW.Constants.BagType Type, List<(uint ModelId, string EncodedName, string? DecodedName, string EncodedSingleName, string? DecodedSingleName, string EncodedCompleteName, string? DecodedCompleteName, bool Inscribable, int Quantity, uint[] Modifiers, ItemType ItemType, uint Interaction, uint ModelFileId, byte DyeTint, ushort Value)> Items)>();
|
||||
foreach (var tuple in itemTuples)
|
||||
{
|
||||
var decodedItems = new List<(uint ModelId, string EncodedName, string? DecodedName, string EncodedSingleName, string? DecodedSingleName, string EncodedCompleteName, string? DecodedCompleteName, bool Inscribable, int Quantity, uint[] Modifiers, ItemType ItemType, uint Interaction, uint ModelFileId, byte DyeTint, ushort Value)>();
|
||||
foreach (var item in tuple.Item2)
|
||||
{
|
||||
var decodedName = await this.uIService.DecodeString(item.EncodedName, (GWCA.GW.Constants.Language)Language.English, cancellationToken);
|
||||
var decodedCompleteName = await this.uIService.DecodeString(item.EncodedCompleteName, (GWCA.GW.Constants.Language)Language.English, cancellationToken);
|
||||
var decodedSingleName = await this.uIService.DecodeString(item.EncodedSingleName, (GWCA.GW.Constants.Language)Language.English, cancellationToken);
|
||||
decodedItems.Add((item.ModelId, item.EncodedName, decodedName, item.EncodedSingleName, decodedSingleName, item.EncodedCompleteName, decodedCompleteName, item.Inscribable, item.Quantity, item.Modifiers, item.ItemType, item.Interaction, item.ModelFileId, item.DyeTint, item.Value));
|
||||
}
|
||||
|
||||
decodedItemTuples.Add((tuple.Type, decodedItems));
|
||||
}
|
||||
|
||||
return new InventoryInformation(
|
||||
Bags: [.. decodedItemTuples.Select(tuple =>
|
||||
new BagEntry(
|
||||
BagType: tuple.Type.ToString(),
|
||||
Items: [.. tuple.Items.Select(item =>
|
||||
new ItemEntry(
|
||||
ItemModelId: item.ModelId,
|
||||
EncodedName: ToBase64(item.EncodedName),
|
||||
DecodedName: item.DecodedName ?? string.Empty,
|
||||
EncodedSingleName: ToBase64(item.EncodedSingleName),
|
||||
DecodedSingleName: item.DecodedSingleName ?? string.Empty,
|
||||
EncodedCompleteName: ToBase64(item.EncodedCompleteName),
|
||||
DecodedCompleteName: item.DecodedCompleteName ?? string.Empty,
|
||||
Inscribable: item.Inscribable,
|
||||
Quantity: item.Quantity,
|
||||
Modifiers: item.Modifiers,
|
||||
Properties: [.. ItemProperty.ParseItemModifiers([.. item.Modifiers.Select(m => (Shared.Models.Guildwars.ItemModifier)m)])],
|
||||
ItemType: item.ItemType.ToString(),
|
||||
Interaction: item.Interaction,
|
||||
ModelFileId: item.ModelFileId,
|
||||
DyeTint: item.DyeTint,
|
||||
Value: item.Value))]))]);
|
||||
}
|
||||
|
||||
private static string ToBase64(string encoded)
|
||||
{
|
||||
return Convert.ToBase64String(Encoding.Unicode.GetBytes(encoded));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Daybreak.API.Services.Interop;
|
||||
using Daybreak.Shared.Models.Api;
|
||||
using System.Core.Extensions;
|
||||
using System.Extensions.Core;
|
||||
|
||||
namespace Daybreak.API.Services;
|
||||
|
||||
public sealed class LoginService(
|
||||
GameThreadService gameThreadService,
|
||||
GameContextService gameContextService,
|
||||
ILogger<LoginService> logger)
|
||||
{
|
||||
private readonly GameThreadService gameThreadService = gameThreadService.ThrowIfNull();
|
||||
private readonly GameContextService gameContextService = gameContextService.ThrowIfNull();
|
||||
private readonly ILogger<LoginService> logger = logger.ThrowIfNull();
|
||||
|
||||
public Task<LoginInfo?> GetLoginInformation(CancellationToken cancellationToken)
|
||||
{
|
||||
var scopedLogger = this.logger.CreateScopedLogger();
|
||||
return this.gameThreadService.QueueOnGameThread(() =>
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
var gameContext = this.gameContextService.GetGameContext();
|
||||
if (gameContext.IsNull || gameContext.Pointer->Character is null)
|
||||
{
|
||||
scopedLogger.LogError("Game context is not initialized");
|
||||
return default;
|
||||
}
|
||||
|
||||
var playerName = new string(gameContext.Pointer->Character->PlayerName);
|
||||
var email = new string(gameContext.Pointer->Character->PlayerEmail);
|
||||
return new LoginInfo(email, playerName);
|
||||
}
|
||||
}, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
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;
|
||||
using Daybreak.Shared.Models.Api;
|
||||
using Daybreak.Shared.Models.Builds;
|
||||
using Daybreak.Shared.Services.BuildTemplates;
|
||||
using System.Core.Extensions;
|
||||
using System.Extensions;
|
||||
using System.Extensions.Core;
|
||||
using System.Runtime.CompilerServices;
|
||||
using ZLinq;
|
||||
using InstanceType = Daybreak.API.Interop.GuildWars.InstanceType;
|
||||
|
||||
namespace Daybreak.API.Services;
|
||||
|
||||
public sealed class MainPlayerService : IDisposable
|
||||
{
|
||||
private readonly ChatService chatService;
|
||||
private readonly InstanceContextService instanceContextService;
|
||||
private readonly IBuildTemplateManager buildTemplateManager;
|
||||
private readonly SkillbarContextService skillbarContextService;
|
||||
private readonly AgentContextService agentContextService;
|
||||
private readonly GameContextService gameContextService;
|
||||
private readonly GameThreadService gameThreadService;
|
||||
private readonly ILogger<MainPlayerService> logger;
|
||||
|
||||
public MainPlayerService(
|
||||
ChatService chatService,
|
||||
InstanceContextService instanceContextService,
|
||||
IBuildTemplateManager buildTemplateManager,
|
||||
SkillbarContextService skillbarContextService,
|
||||
GameContextService gameContextService,
|
||||
AgentContextService agentContextService,
|
||||
GameThreadService gameThreadService,
|
||||
ILogger<MainPlayerService> logger)
|
||||
{
|
||||
this.chatService = chatService.ThrowIfNull();
|
||||
this.instanceContextService = instanceContextService.ThrowIfNull();
|
||||
this.buildTemplateManager = buildTemplateManager.ThrowIfNull();
|
||||
this.skillbarContextService = skillbarContextService.ThrowIfNull();
|
||||
this.gameThreadService = gameThreadService.ThrowIfNull();
|
||||
this.agentContextService = agentContextService.ThrowIfNull();
|
||||
this.gameContextService = gameContextService.ThrowIfNull();
|
||||
this.logger = logger.ThrowIfNull();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
public async Task<bool> SetCurrentBuild(string buildCode, CancellationToken cancellationToken)
|
||||
{
|
||||
var scopedLogger = this.logger.CreateScopedLogger();
|
||||
if (!this.buildTemplateManager.TryDecodeTemplate(buildCode, out var build) ||
|
||||
build is not SingleBuildEntry singleBuild)
|
||||
{
|
||||
scopedLogger.LogError("Failed to decode build template from code {buildCode}", buildCode);
|
||||
return false;
|
||||
}
|
||||
|
||||
var result = await this.gameThreadService.QueueOnGameThread(() =>
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
if (this.instanceContextService.GetInstanceType() is InstanceType.Loading or InstanceType.Explorable)
|
||||
{
|
||||
scopedLogger.LogError("Not in outpost");
|
||||
return default;
|
||||
}
|
||||
|
||||
var gameContext = this.gameContextService.GetGameContext();
|
||||
if (gameContext.IsNull ||
|
||||
gameContext.Pointer->World is null)
|
||||
{
|
||||
scopedLogger.LogError("Failed to get game context");
|
||||
return false;
|
||||
}
|
||||
|
||||
var playerAgentId = this.agentContextService.GetPlayerAgentId();
|
||||
if (playerAgentId is 0x0)
|
||||
{
|
||||
scopedLogger.LogError("Failed to get player agent id");
|
||||
return false;
|
||||
}
|
||||
|
||||
var playerAgent = this.agentContextService.GetAgentById(playerAgentId);
|
||||
if (playerAgent is null ||
|
||||
playerAgent->Type is not (uint)AgentType.Living ||
|
||||
playerAgent->AgentId != playerAgentId)
|
||||
{
|
||||
scopedLogger.LogError("Player agent {playerAgentId} not found in agent array", playerAgentId);
|
||||
return false;
|
||||
}
|
||||
|
||||
var livingAgent = (AgentLiving*)playerAgent;
|
||||
if (livingAgent->Level != gameContext.Pointer->World->Level)
|
||||
{
|
||||
scopedLogger.LogError("Player agent not found. Player level mismatch: {level} != {gameLevel}", livingAgent->Level, gameContext.Pointer->World->Level);
|
||||
return false;
|
||||
}
|
||||
|
||||
var agentProfession = gameContext.Pointer->World->PartyProfessionStates.AsValueEnumerable().FirstOrDefault(p => p.AgentId == playerAgentId);
|
||||
if (agentProfession.AgentId != playerAgentId)
|
||||
{
|
||||
scopedLogger.LogError("Failed to find agent profession for player agent id {agentId}", playerAgentId);
|
||||
return false;
|
||||
}
|
||||
|
||||
var validationRequest = new BuildTemplateValidationRequest(
|
||||
(uint)singleBuild.Primary.Id,
|
||||
(uint)singleBuild.Secondary.Id,
|
||||
singleBuild.Skills.AsValueEnumerable().Select(s => (uint)s.Id).ToArray(),
|
||||
livingAgent->Tags->Primary,
|
||||
agentProfession.UnlockedProfessions,
|
||||
[.. gameContext.Pointer->World->UnlockedCharacterSkills]);
|
||||
|
||||
if (!this.buildTemplateManager.CanTemplateApply(validationRequest))
|
||||
{
|
||||
scopedLogger.LogError("Build template validation failed for player agent id {agentId}", playerAgentId);
|
||||
return false;
|
||||
}
|
||||
|
||||
var attributeIds = new AttributeArray12();
|
||||
var attributeValues = new Array12Uint();
|
||||
var skills = new SkillIDArray8();
|
||||
for (var i = 0; i < singleBuild.Attributes.Count && i < 12; i++)
|
||||
{
|
||||
if (singleBuild.Attributes[i].Attribute is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
attributeIds[i] = (GWCA.GW.Constants.Attribute)singleBuild.Attributes[i].Attribute!.Id;
|
||||
attributeValues[i] = (uint)singleBuild.Attributes[i].Points;
|
||||
}
|
||||
|
||||
for (var i = 0; i < singleBuild.Skills.Count && i < 8; i++)
|
||||
{
|
||||
skills[i] = (GWCA.GW.Constants.SkillID)singleBuild.Skills[i].Id;
|
||||
}
|
||||
|
||||
var skillTemplate = new SkillTemplate
|
||||
{
|
||||
Primary = (GWCA.GW.Constants.Profession)singleBuild.Primary.Id,
|
||||
Secondary = (GWCA.GW.Constants.Profession)singleBuild.Secondary.Id,
|
||||
AttributesCount = (uint)Math.Min(singleBuild.Attributes.Count, 12),
|
||||
AttributeIds = attributeIds,
|
||||
Skills = skills,
|
||||
};
|
||||
Unsafe.CopyBlock(skillTemplate.AttributeValues, Unsafe.AsPointer(ref attributeValues), (uint)(12 * sizeof(uint)));
|
||||
this.skillbarContextService.LoadBuild(playerAgentId, &skillTemplate);
|
||||
return true;
|
||||
}
|
||||
}, cancellationToken);
|
||||
|
||||
if (result)
|
||||
{
|
||||
await this.chatService.AddMessageAsync("Build applied successfully.", "Daybreak.API", Channel.CHANNEL_MODERATOR, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await this.chatService.AddMessageAsync("Failed to apply build.", "Daybreak.API", Channel.CHANNEL_MODERATOR, cancellationToken);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public Task<BuildEntry?> GetCurrentBuild(CancellationToken cancellationToken)
|
||||
{
|
||||
var scopedLogger = this.logger.CreateScopedLogger();
|
||||
return this.gameThreadService.QueueOnGameThread(() =>
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
if (this.instanceContextService.GetInstanceType() is InstanceType.Loading)
|
||||
{
|
||||
scopedLogger.LogError("Not loaded");
|
||||
return default;
|
||||
}
|
||||
|
||||
var gameContext = this.gameContextService.GetGameContext();
|
||||
if (gameContext.IsNull)
|
||||
{
|
||||
scopedLogger.LogError("Failed to get game context");
|
||||
return default;
|
||||
}
|
||||
|
||||
var playerAgentId = this.agentContextService.GetPlayerAgentId();
|
||||
if (playerAgentId is 0x0)
|
||||
{
|
||||
scopedLogger.LogError("Failed to get player agent id");
|
||||
return default;
|
||||
}
|
||||
|
||||
var skillbarContext = gameContext.Pointer->World->Skillbar.Value.AsValueEnumerable().FirstOrDefault(s => s.AgentId == playerAgentId);
|
||||
if (skillbarContext.AgentId != playerAgentId)
|
||||
{
|
||||
scopedLogger.LogError("Failed to find skillbar context for player agent id {agentId}", playerAgentId);
|
||||
return default;
|
||||
}
|
||||
|
||||
var agentAttributes = gameContext.Pointer->World->Attributes.Value.AsValueEnumerable().FirstOrDefault(a => a.AgentId == playerAgentId);
|
||||
if (agentAttributes.AgentId != playerAgentId)
|
||||
{
|
||||
scopedLogger.LogError("Failed to find agent attributes for player agent id {agentId}", playerAgentId);
|
||||
return default;
|
||||
}
|
||||
|
||||
var professionContext = gameContext.Pointer->World->PartyProfessionStates.AsValueEnumerable().FirstOrDefault(p => p.AgentId == playerAgentId);
|
||||
if (professionContext.AgentId != playerAgentId)
|
||||
{
|
||||
scopedLogger.LogError("Failed to find agent profession for player agent id {agentId}", playerAgentId);
|
||||
return default;
|
||||
}
|
||||
|
||||
return new BuildEntry(
|
||||
(int)professionContext.Primary,
|
||||
(int)professionContext.Secondary,
|
||||
[.. agentAttributes.Attribute.GetAttributeEntryList()],
|
||||
[(uint)skillbarContext.Skills[0].SkillId, (uint)skillbarContext.Skills[1].SkillId, (uint)skillbarContext.Skills[2].SkillId, (uint)skillbarContext.Skills[3].SkillId,
|
||||
(uint)skillbarContext.Skills[4].SkillId, (uint)skillbarContext.Skills[5].SkillId, (uint)skillbarContext.Skills[6].SkillId, (uint)skillbarContext.Skills[7].SkillId]);
|
||||
}
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<QuestLogInformation?> GetQuestLog(CancellationToken cancellationToken)
|
||||
{
|
||||
var scopedLogger = this.logger.CreateScopedLogger();
|
||||
return this.gameThreadService.QueueOnGameThread(() =>
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
if (this.instanceContextService.GetInstanceType() is InstanceType.Loading)
|
||||
{
|
||||
scopedLogger.LogError("Not loaded");
|
||||
return default;
|
||||
}
|
||||
|
||||
var gameContext = this.gameContextService.GetGameContext();
|
||||
if (gameContext.IsNull || gameContext.Pointer->World is null)
|
||||
{
|
||||
scopedLogger.LogError("Game context is not initialized");
|
||||
return default;
|
||||
}
|
||||
|
||||
return new QuestLogInformation(
|
||||
(uint)gameContext.Pointer->World->ActiveQuestId,
|
||||
gameContext.Pointer->World->QuestLog.Value.AsValueEnumerable().Select(q => new QuestInformation((uint)q.QuestId, (uint)q.MapFrom)).ToList());
|
||||
}
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<MainPlayerInformation?> GetMainPlayerInformation(CancellationToken cancellationToken)
|
||||
{
|
||||
var scopedLogger = this.logger.CreateScopedLogger();
|
||||
return this.gameThreadService.QueueOnGameThread(() =>
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
if (this.instanceContextService.GetInstanceType() is InstanceType.Loading)
|
||||
{
|
||||
scopedLogger.LogError("Not loaded");
|
||||
return default;
|
||||
}
|
||||
|
||||
var gameContext = this.gameContextService.GetGameContext();
|
||||
if (gameContext.IsNull || gameContext.Pointer->Character is null ||
|
||||
gameContext.Pointer->World is null)
|
||||
{
|
||||
scopedLogger.LogError("Game context is not initialized");
|
||||
return default;
|
||||
}
|
||||
|
||||
var playerName = new string (gameContext.Pointer->Character->PlayerName);
|
||||
var email = new string(gameContext.Pointer->Character->PlayerEmail);
|
||||
var accountName = new string((char*)gameContext.Pointer->World->AccountInfo->AccountName);
|
||||
return new MainPlayerInformation(
|
||||
(*(Uuid*)gameContext.Pointer->Character->PlayerUuid).ToString(),
|
||||
email,
|
||||
playerName,
|
||||
accountName,
|
||||
gameContext.Pointer->World->AccountInfo->Wins,
|
||||
gameContext.Pointer->World->AccountInfo->Losses,
|
||||
gameContext.Pointer->World->AccountInfo->Rating,
|
||||
gameContext.Pointer->World->AccountInfo->QualifierPoints,
|
||||
gameContext.Pointer->World->AccountInfo->Rank,
|
||||
gameContext.Pointer->World->AccountInfo->TournamentRewardPoints);
|
||||
}
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<MainPlayerState?> GetMainPlayerState(CancellationToken cancellationToken)
|
||||
{
|
||||
var scopedLogger = this.logger.CreateScopedLogger();
|
||||
return this.gameThreadService.QueueOnGameThread(() =>
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
if (this.instanceContextService.GetInstanceType() is InstanceType.Loading)
|
||||
{
|
||||
scopedLogger.LogError("Not loaded");
|
||||
return default;
|
||||
}
|
||||
|
||||
var gameContext = this.gameContextService.GetGameContext();
|
||||
var playerAgentId = this.agentContextService.GetPlayerAgentId();
|
||||
if (gameContext.IsNull ||
|
||||
gameContext.Pointer->World is null ||
|
||||
gameContext.Pointer->Character is null ||
|
||||
playerAgentId is 0x0)
|
||||
{
|
||||
scopedLogger.LogDebug("Game data is not yet initialized");
|
||||
return default;
|
||||
}
|
||||
|
||||
var playerAgent = this.agentContextService.GetAgentById(playerAgentId);
|
||||
if (playerAgent is null ||
|
||||
playerAgent->Type is not (uint)AgentType.Living ||
|
||||
playerAgent->AgentId != playerAgentId)
|
||||
{
|
||||
scopedLogger.LogError("Player agent {playerAgentId} not found in agent array", playerAgentId);
|
||||
return default;
|
||||
}
|
||||
|
||||
var livingAgent = (AgentLiving*)playerAgent;
|
||||
if (livingAgent->Level != gameContext.Pointer->World->Level)
|
||||
{
|
||||
scopedLogger.LogError("Player agent not found. Player level mismatch: {level} != {gameLevel}", livingAgent->Level, gameContext.Pointer->World->Level);
|
||||
return default;
|
||||
}
|
||||
|
||||
return new MainPlayerState(
|
||||
gameContext.Pointer->World->Experience,
|
||||
gameContext.Pointer->World->Level,
|
||||
|
||||
gameContext.Pointer->World->CurrentLuxon,
|
||||
gameContext.Pointer->World->CurrentKurzick,
|
||||
gameContext.Pointer->World->CurrentImperial,
|
||||
gameContext.Pointer->World->CurrentBalth,
|
||||
gameContext.Pointer->World->MaxLuxon,
|
||||
gameContext.Pointer->World->MaxKurzick,
|
||||
gameContext.Pointer->World->MaxImperial,
|
||||
gameContext.Pointer->World->MaxBalth,
|
||||
gameContext.Pointer->World->TotalEarnedLuxon,
|
||||
gameContext.Pointer->World->TotalEarnedKurzick,
|
||||
gameContext.Pointer->World->TotalEarnedImperial,
|
||||
gameContext.Pointer->World->TotalEarnedBalth,
|
||||
|
||||
// Energy and health are percentages of Max
|
||||
livingAgent->Hp * livingAgent->MaxHp,
|
||||
livingAgent->MaxHp,
|
||||
livingAgent->Energy * livingAgent->MaxEnergy,
|
||||
livingAgent->MaxEnergy,
|
||||
|
||||
livingAgent->Tags->Primary,
|
||||
livingAgent->Tags->Secondary,
|
||||
|
||||
playerAgent->Pos.X,
|
||||
playerAgent->Pos.Y
|
||||
);
|
||||
}
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<InstanceInfo> GetMainPlayerInstance(CancellationToken cancellationToken)
|
||||
{
|
||||
var scopedLogger = this.logger.CreateScopedLogger();
|
||||
return this.gameThreadService.QueueOnGameThread(() =>
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
var instanceType = this.instanceContextService.GetInstanceType();
|
||||
if (instanceType is InstanceType.Loading)
|
||||
{
|
||||
return new InstanceInfo(0, 0, 0, 0, Shared.Models.Api.InstanceType.Loading, DistrictRegionInfo.Unknown, LanguageInfo.Unknown, CampaignInfo.Unknown, ContinentInfo.Unknown, RegionInfo.Unknown, DifficultyInfo.Unknown);
|
||||
}
|
||||
|
||||
var gameContext = this.gameContextService.GetGameContext();
|
||||
var currentMapInfo = this.instanceContextService.GetAreaInfo();
|
||||
var serverRegion = this.instanceContextService.GetServerRegion();
|
||||
if (gameContext.IsNull ||
|
||||
gameContext.Pointer->Character is null ||
|
||||
gameContext.Pointer->World is null ||
|
||||
gameContext.Pointer->Party is 0 ||
|
||||
currentMapInfo.IsNull)
|
||||
{
|
||||
scopedLogger.LogError("Game context is not initialized");
|
||||
return new InstanceInfo(0, 0, 0, 0, Shared.Models.Api.InstanceType.Loading, DistrictRegionInfo.Unknown, LanguageInfo.Unknown, CampaignInfo.Unknown, ContinentInfo.Unknown, RegionInfo.Unknown, DifficultyInfo.Unknown);
|
||||
}
|
||||
|
||||
var charContext = *gameContext.Pointer->Character;
|
||||
var worldContext = *gameContext.Pointer->World;
|
||||
var mapInfo = *currentMapInfo.Pointer;
|
||||
var mapId = charContext.MapId;
|
||||
var language = charContext.Language;
|
||||
var districtNumber = charContext.DistrictNumber;
|
||||
var foesKilled = worldContext.FoesKilled;
|
||||
var foesToKill = worldContext.FoesToKill;
|
||||
|
||||
return new InstanceInfo(
|
||||
MapId: (uint)charContext.MapId,
|
||||
DistrictNumber: charContext.DistrictNumber,
|
||||
FoesKilled: worldContext.FoesKilled,
|
||||
FoesToKill: worldContext.FoesToKill,
|
||||
Type: (Shared.Models.Api.InstanceType)instanceType,
|
||||
DistrictRegion: (DistrictRegionInfo)serverRegion,
|
||||
Language: (LanguageInfo)charContext.Language,
|
||||
Campaign: (CampaignInfo)mapInfo.Campaign,
|
||||
Continent: (ContinentInfo)mapInfo.Continent,
|
||||
Region: (RegionInfo)mapInfo.Region,
|
||||
Difficulty: GWCA.GW.PartyMgr.GetIsPartyInHardMode() ? DifficultyInfo.Hard : DifficultyInfo.Normal);
|
||||
}
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<TitleInfo?> GetTitleInfo(CancellationToken cancellationToken)
|
||||
{
|
||||
var scopedLogger = this.logger.CreateScopedLogger();
|
||||
return this.gameThreadService.QueueOnGameThread(() =>
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
if (this.instanceContextService.GetInstanceType() is InstanceType.Loading)
|
||||
{
|
||||
scopedLogger.LogError("Not loaded");
|
||||
return default;
|
||||
}
|
||||
|
||||
var gameContext = this.gameContextService.GetGameContext();
|
||||
if (gameContext.IsNull || gameContext.Pointer->World is null)
|
||||
{
|
||||
scopedLogger.LogError("Game context is not initialized");
|
||||
return default;
|
||||
}
|
||||
|
||||
var playerIndex = gameContext.Pointer->Character->PlayerNumber;
|
||||
var player = GWCA.GW.PlayerMgr.GetPlayerByID(playerIndex);
|
||||
if (player is null)
|
||||
{
|
||||
scopedLogger.LogError("Failed to get player");
|
||||
return default;
|
||||
}
|
||||
|
||||
var currentTier = player->ActiveTitleTier;
|
||||
Title? currentTitle = default;
|
||||
int? id = -1;
|
||||
for (var i = 0; i < gameContext.Pointer->World->Titles.Value.Size; i++)
|
||||
{
|
||||
var title = gameContext.Pointer->World->Titles.Value.Skip(i).FirstOrDefault();
|
||||
if (title.CurrentTitleTierIndex == currentTier)
|
||||
{
|
||||
currentTitle = title;
|
||||
id = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentTitle.HasValue)
|
||||
{
|
||||
scopedLogger.LogError("Failed to find current title with tier {currentTier}", currentTier);
|
||||
return default;
|
||||
}
|
||||
|
||||
var titleTier = gameContext.Pointer->World->TitleTiers.Skip((int)currentTitle.Value.CurrentTitleTierIndex).FirstOrDefault();
|
||||
return new TitleInfo
|
||||
(
|
||||
Id: (uint)id,
|
||||
IsPercentage: currentTitle.Value.IsPercentageBased(),
|
||||
PointsForCurrentRank: currentTitle.Value.PointsNeededCurrentRank,
|
||||
PointsForNextRank: titleTier.TierNumber == currentTitle.Value.MaxTitleRank ?
|
||||
currentTitle.Value.CurrentPoints :
|
||||
currentTitle.Value.PointsNeededNextRank,
|
||||
CurrentPoints: currentTitle.Value.CurrentPoints,
|
||||
TierNumber: titleTier.TierNumber,
|
||||
MaxTierNumber: currentTitle.Value.MaxTitleTierIndex
|
||||
);
|
||||
}
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<MainPlayerBuildContext?> GetMainPlayerBuildContext(CancellationToken cancellationToken)
|
||||
{
|
||||
var scopedLogger = this.logger.CreateScopedLogger();
|
||||
return this.gameThreadService.QueueOnGameThread(() =>
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
if (this.instanceContextService.GetInstanceType() is InstanceType.Loading)
|
||||
{
|
||||
scopedLogger.LogError("Not loaded");
|
||||
return default;
|
||||
}
|
||||
|
||||
var gameContext = this.gameContextService.GetGameContext();
|
||||
if (gameContext.IsNull ||
|
||||
gameContext.Pointer->Account is null ||
|
||||
gameContext.Pointer->World is null)
|
||||
{
|
||||
this.logger.LogError("Game context is not initialized");
|
||||
return default;
|
||||
}
|
||||
|
||||
var playerAgentId = this.agentContextService.GetPlayerAgentId();
|
||||
if (playerAgentId is 0x0)
|
||||
{
|
||||
scopedLogger.LogError("Failed to get player agent id");
|
||||
return default;
|
||||
}
|
||||
|
||||
var agentProfession = gameContext.Pointer->World->PartyProfessionStates.AsValueEnumerable().FirstOrDefault(p => p.AgentId == playerAgentId);
|
||||
if (agentProfession.AgentId != playerAgentId)
|
||||
{
|
||||
scopedLogger.LogError("Failed to find agent profession for player agent id {agentId}", playerAgentId);
|
||||
return default;
|
||||
}
|
||||
|
||||
return new MainPlayerBuildContext(
|
||||
PrimaryProfessionId: (uint)agentProfession.Primary,
|
||||
UnlockedProfessions: agentProfession.UnlockedProfessions,
|
||||
UnlockedCharacterSkills: gameContext.Pointer->World->UnlockedCharacterSkills.AsValueEnumerable().ToArray());
|
||||
|
||||
}
|
||||
}, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
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;
|
||||
using Daybreak.Shared.Models.Api;
|
||||
using Daybreak.Shared.Models.Guildwars;
|
||||
using Daybreak.Shared.Services.BuildTemplates;
|
||||
using System.Core.Extensions;
|
||||
using System.Extensions;
|
||||
using System.Extensions.Core;
|
||||
using System.Runtime.CompilerServices;
|
||||
using ZLinq;
|
||||
using InstanceType = Daybreak.API.Interop.GuildWars.InstanceType;
|
||||
|
||||
namespace Daybreak.API.Services;
|
||||
|
||||
public sealed class PartyService(
|
||||
ChatService chatService,
|
||||
IBuildTemplateManager buildTemplateManager,
|
||||
UIService uiService,
|
||||
UIContextService uIContextService,
|
||||
SkillbarContextService skillbarContextService,
|
||||
InstanceContextService instanceContextService,
|
||||
PartyContextService partyContextService,
|
||||
GameThreadService gameThreadService,
|
||||
GameContextService gameContextService,
|
||||
ILogger<PartyService> logger)
|
||||
{
|
||||
private static readonly TimeSpan HeroSpawnDelay = TimeSpan.FromSeconds(1);
|
||||
|
||||
private readonly ChatService chatService = chatService.ThrowIfNull();
|
||||
private readonly IBuildTemplateManager buildTemplateManager = buildTemplateManager.ThrowIfNull();
|
||||
private readonly UIService uiService = uiService.ThrowIfNull();
|
||||
private readonly UIContextService uIContextService = uIContextService.ThrowIfNull();
|
||||
private readonly SkillbarContextService skillbarContextService = skillbarContextService.ThrowIfNull();
|
||||
private readonly InstanceContextService instanceContextService = instanceContextService.ThrowIfNull();
|
||||
private readonly PartyContextService partyContextService = partyContextService.ThrowIfNull();
|
||||
private readonly GameThreadService gameThreadService = gameThreadService.ThrowIfNull();
|
||||
private readonly GameContextService gameContextService = gameContextService.ThrowIfNull();
|
||||
private readonly ILogger<PartyService> logger = logger.ThrowIfNull();
|
||||
|
||||
public async Task<bool> SetPartyLoadout(PartyLoadout partyLoadout, CancellationToken cancellationToken)
|
||||
{
|
||||
var scopedLogger = this.logger.CreateScopedLogger();
|
||||
|
||||
if (!await this.IsInValidOutpost(cancellationToken))
|
||||
{
|
||||
scopedLogger.LogError("Could not set party loadout. Not in a valid outpost");
|
||||
await this.chatService.AddMessageAsync("Cannot set party loadout. Not in a valid outpost.", "Daybreak.API", Channel.CHANNEL_MODERATOR, cancellationToken);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!await this.KickAllHeroes(cancellationToken))
|
||||
{
|
||||
scopedLogger.LogError("Could not set party loadout. Could not leave party");
|
||||
await this.chatService.AddMessageAsync("Cannot set party loadout. Could not leave party.", "Daybreak.API", Channel.CHANNEL_MODERATOR, cancellationToken);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!await this.gameThreadService.QueueOnGameThread(() => this.SpawnHeroes(partyLoadout), cancellationToken))
|
||||
{
|
||||
scopedLogger.LogError("Could not set party loadout. Could not spawn heroes");
|
||||
await this.chatService.AddMessageAsync("Cannot set party loadout. Could not spawn heroes.", "Daybreak.API", Channel.CHANNEL_MODERATOR, cancellationToken);
|
||||
return false;
|
||||
}
|
||||
|
||||
await Task.Delay(HeroSpawnDelay, cancellationToken);
|
||||
|
||||
if (!await this.gameThreadService.QueueOnGameThread(() => this.ApplyBuilds(partyLoadout), cancellationToken))
|
||||
{
|
||||
scopedLogger.LogError("Could not set party loadout. Could not apply builds");
|
||||
await this.chatService.AddMessageAsync("Cannot set party loadout. Could not apply builds.", "Daybreak.API", Channel.CHANNEL_MODERATOR, cancellationToken);
|
||||
return false;
|
||||
}
|
||||
|
||||
var heroBehaviorSetup = await this.gameThreadService.QueueOnGameThread(() => this.GetHeroBehaviorSetup(partyLoadout), cancellationToken);
|
||||
foreach (var (AgentId, Behavior) in heroBehaviorSetup ?? [])
|
||||
{
|
||||
if (!await this.SetHeroBehavior(AgentId, Behavior, cancellationToken))
|
||||
{
|
||||
scopedLogger.LogWarning("Could not set hero behavior for agent {agentId} to {behavior}", AgentId, Behavior);
|
||||
await this.chatService.AddMessageAsync($"Cannot set party loadout. Could not set behavior {Behavior} for agent {AgentId}.", "Daybreak.API", Channel.CHANNEL_MODERATOR, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
await this.chatService.AddMessageAsync("Party loadout set.", "Daybreak.API", Channel.CHANNEL_MODERATOR, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
public Task<PartyLoadout?> GetPartyLoadout(CancellationToken cancellationToken)
|
||||
{
|
||||
var scopedLogger = this.logger.CreateScopedLogger();
|
||||
return this.gameThreadService.QueueOnGameThread(() =>
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
if (this.instanceContextService.GetInstanceType() is InstanceType.Loading or InstanceType.Explorable)
|
||||
{
|
||||
scopedLogger.LogError("Not in outpost");
|
||||
return default;
|
||||
}
|
||||
|
||||
if (!this.gameContextService.GetGameContext().TryGetBuildContext(out var skillbars, out var attributes, out var professions, out _))
|
||||
{
|
||||
scopedLogger.LogError("Failed to get build context");
|
||||
return default;
|
||||
}
|
||||
|
||||
if (!this.gameContextService.GetGameContext().TryGetHeroFlags(out var heroFlags))
|
||||
{
|
||||
scopedLogger.LogError("Failed to get hero flags");
|
||||
return default;
|
||||
}
|
||||
|
||||
if (!this.gameContextService.GetGameContext().TryGetPlayerId(out var playerId))
|
||||
{
|
||||
scopedLogger.LogError("Failed to get player id");
|
||||
return default;
|
||||
}
|
||||
|
||||
if (!this.gameContextService.GetGameContext().TryGetPlayerParty(out _, out _, out var heroes, out _))
|
||||
{
|
||||
scopedLogger.LogError("Failed to get player party");
|
||||
return default;
|
||||
}
|
||||
|
||||
var buildTuples = professions.Value.AsValueEnumerable()
|
||||
.Select(p => (p.AgentId, p, attributes.Value.FirstOrDefault(a => a.AgentId == p.AgentId), skillbars.Value.FirstOrDefault(s => s.AgentId == p.AgentId), heroFlags.Value.FirstOrDefault(f => f.AgentId == p.AgentId)))
|
||||
.Select(t => (t.AgentId, GetBuildEntryById(t.p, t.Item4, t.Item3), t.Item5.HeroBehavior))
|
||||
.Where(t => t.Item2 is not null)
|
||||
.OfType<(uint AgentId, BuildEntry BuildEntry, API.Interop.GWCA.GW.HeroBehavior Behavior)>()
|
||||
.ToList();
|
||||
|
||||
return new PartyLoadout(
|
||||
[.. buildTuples.Select(t =>
|
||||
{
|
||||
if (t.AgentId == playerId)
|
||||
{
|
||||
return new PartyLoadoutEntry(0, Shared.Models.Api.HeroBehavior.Undefined, t.BuildEntry);
|
||||
}
|
||||
else if (heroes.Value.FirstOrDefault(h => h.AgentId == t.AgentId) is HeroPartyMember hero &&
|
||||
hero.AgentId == t.AgentId)
|
||||
{
|
||||
return new PartyLoadoutEntry((int)hero.HeroId, (Shared.Models.Api.HeroBehavior)t.Behavior, t.BuildEntry);
|
||||
}
|
||||
|
||||
return default;
|
||||
})
|
||||
.Where(t => t is not null)
|
||||
.OfType<PartyLoadoutEntry>()]);
|
||||
}
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> KickAllHeroes(CancellationToken cancellationToken)
|
||||
{
|
||||
var partySize = await this.GetPartySize(cancellationToken);
|
||||
if (partySize is 1 or 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return await this.gameThreadService.QueueOnGameThread(this.partyContextService.KickAllHeroes, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<uint> GetPartySize(CancellationToken cancellationToken)
|
||||
{
|
||||
return await this.gameThreadService.QueueOnGameThread<uint>(() =>
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
if (!this.gameContextService.GetGameContext().TryGetPlayerParty(out _, out var players, out var heroes, out var henchmen))
|
||||
{
|
||||
this.logger.LogError("Failed to get player party");
|
||||
return 0;
|
||||
}
|
||||
|
||||
return players.Value.Size + heroes.Value.Size + heroes.Value.Size;
|
||||
}
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> SetHeroBehavior(uint heroAgentId, Shared.Models.Api.HeroBehavior behavior, CancellationToken cancellationToken)
|
||||
{
|
||||
var scopedLogger = this.logger.CreateScopedLogger();
|
||||
|
||||
if (!await this.IsInValidOutpost(cancellationToken))
|
||||
{
|
||||
scopedLogger.LogError("Could not set hero behavior. Not in a valid outpost");
|
||||
return false;
|
||||
}
|
||||
|
||||
var heroCommanderFrameLabel = await this.HeroCommanderFrameLabel(heroAgentId, cancellationToken);
|
||||
if (heroCommanderFrameLabel is null)
|
||||
{
|
||||
scopedLogger.LogError("Could not set hero behavior. Could not find hero commander frame label for agent {heroAgentId}", heroAgentId);
|
||||
return false;
|
||||
}
|
||||
|
||||
var maybeFrame = await this.uiService.GetManagedFrame(heroCommanderFrameLabel, cancellationToken);
|
||||
if (!maybeFrame.HasValue)
|
||||
{
|
||||
scopedLogger.LogError("Could not set hero behavior. Could not get managed frame for label {heroCommanderFrameLabel}", heroCommanderFrameLabel);
|
||||
return false;
|
||||
}
|
||||
|
||||
using var frame = maybeFrame.Value;
|
||||
var result = await this.gameThreadService.QueueOnGameThread(() =>
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
var btn = this.uIContextService.GetChildFrame(frame.Frame, (uint)behavior);
|
||||
var packet = new kMouseClick { MouseButton = 0, IsDoubleclick = 0 };
|
||||
if (btn.IsNull)
|
||||
{
|
||||
scopedLogger.LogError("Could not set hero behavior. Could not find button for behavior {behavior} in frame {frameLabel}", behavior, heroCommanderFrameLabel);
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.uIContextService.SendFrameUIMessage(btn, UIMessage.kMouseClick, &packet);
|
||||
}
|
||||
}, cancellationToken);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
scopedLogger.LogError("Failed to send UI message to set hero behavior {behavior} for agent {heroAgentId}", behavior, heroAgentId);
|
||||
}
|
||||
|
||||
scopedLogger.LogInformation("Set hero behavior {behavior} for agent {agentId}", behavior, heroAgentId);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<bool> IsInValidOutpost(CancellationToken cancellationToken)
|
||||
{
|
||||
return await this.gameThreadService.QueueOnGameThread(() =>
|
||||
{
|
||||
return this.instanceContextService.GetInstanceType() is InstanceType.Outpost;
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
private unsafe bool SpawnHeroes(PartyLoadout partyLoadout)
|
||||
{
|
||||
var scopedLogger = this.logger.CreateScopedLogger();
|
||||
scopedLogger.LogDebug("Spawning {heroCount} heroes for party loadout", partyLoadout.Entries.AsValueEnumerable().Count(c => c.HeroId != 0));
|
||||
foreach (var entry in partyLoadout.Entries.AsValueEnumerable().OrderBy(e => e.Build.Primary))
|
||||
{
|
||||
if (entry.HeroId != 0 &&
|
||||
Hero.TryParse(entry.HeroId, out var hero))
|
||||
{
|
||||
scopedLogger.LogInformation("Adding hero [{heroId}] [{heroName}] with behavior {behavior}", entry.HeroId, hero.Name, entry.HeroBehavior);
|
||||
this.partyContextService.AddHero((uint)entry.HeroId);
|
||||
}
|
||||
else
|
||||
{
|
||||
scopedLogger.LogWarning("Invalid hero entry in party loadout: {heroId}", entry.HeroId);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private unsafe bool ApplyBuilds(PartyLoadout partyLoadout)
|
||||
{
|
||||
var scopedLogger = this.logger.CreateScopedLogger();
|
||||
scopedLogger.LogDebug("Applying builds");
|
||||
|
||||
if (!this.gameContextService.GetGameContext().TryGetPlayerParty(out _, out var players, out var heroes, out var henchmen))
|
||||
{
|
||||
scopedLogger.LogError("Failed to get player party");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.gameContextService.GetGameContext().TryGetBuildContext(out _, out _, out var professions, out var unlockedSkills))
|
||||
{
|
||||
scopedLogger.LogError("Failed to get build context");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.gameContextService.GetGameContext().TryGetPlayerId(out var playerId))
|
||||
{
|
||||
scopedLogger.LogError("Failed to get player id");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.gameContextService.GetGameContext().TryGetAccountContext(out var accountContext) ||
|
||||
accountContext.IsNull)
|
||||
{
|
||||
scopedLogger.LogError("Failed to get account context");
|
||||
return false;
|
||||
}
|
||||
|
||||
var playerProfessionContext = professions.Value.AsValueEnumerable().FirstOrDefault(p => p.AgentId == playerId);
|
||||
if (playerProfessionContext.AgentId != playerId)
|
||||
{
|
||||
scopedLogger.LogError("Failed to get player profession context for player id {playerId}", playerId);
|
||||
return false;
|
||||
}
|
||||
|
||||
var parsedEntries = partyLoadout.Entries.AsValueEnumerable()
|
||||
.Select(entry =>
|
||||
{
|
||||
if (entry.HeroId is 0)
|
||||
{
|
||||
return (entry, playerId);
|
||||
}
|
||||
else
|
||||
{
|
||||
var hero = heroes.Value.AsValueEnumerable().FirstOrDefault(h => (int)h.HeroId == entry.HeroId);
|
||||
return (entry, hero.AgentId);
|
||||
}
|
||||
})
|
||||
.Select(t => (t.entry, t.playerId, professions.Value.AsValueEnumerable().FirstOrDefault(p => p.AgentId == t.playerId)))
|
||||
.Select(t =>
|
||||
{
|
||||
var validSkills = t.playerId == playerId
|
||||
? unlockedSkills
|
||||
: accountContext.Pointer->UnlockedAccountSkills;
|
||||
|
||||
var unlockedProfessionsFlags = t.playerId == playerId
|
||||
? playerProfessionContext.UnlockedProfessions
|
||||
: uint.MaxValue;
|
||||
|
||||
if (!this.buildTemplateManager.CanTemplateApply(
|
||||
new BuildTemplateValidationRequest(
|
||||
(uint)t.entry.Build.Primary,
|
||||
(uint)t.entry.Build.Secondary,
|
||||
[.. t.entry.Build.Skills],
|
||||
(uint)t.Item3.Primary,
|
||||
unlockedProfessionsFlags,
|
||||
[.. validSkills])))
|
||||
{
|
||||
return (t.entry, t.playerId, t.Item3, false);
|
||||
}
|
||||
|
||||
return (t.entry, t.playerId, t.Item3, true);
|
||||
})
|
||||
.Where(t =>
|
||||
{
|
||||
if (!t.Item4)
|
||||
{
|
||||
if (t.entry.HeroId is 0)
|
||||
{
|
||||
scopedLogger.LogError("Cannot apply build for player");
|
||||
}
|
||||
else
|
||||
{
|
||||
scopedLogger.LogError("Cannot apply build for hero {heroId}", t.entry.HeroId);
|
||||
}
|
||||
}
|
||||
|
||||
return t.Item4;
|
||||
});
|
||||
|
||||
foreach (var (entry, maybeAgentId, _, _) in parsedEntries)
|
||||
{
|
||||
if (maybeAgentId is not uint agentId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var attributeIds = new AttributeArray12();
|
||||
var attributeValues = new Array12Uint();
|
||||
for (var i = 0; i < entry.Build.Attributes.Count && i < 12; i++)
|
||||
{
|
||||
attributeIds[i] = (GWCA.GW.Constants.Attribute)entry.Build.Attributes[i].Id;
|
||||
attributeValues[i] = (uint)entry.Build.Attributes[i].BasePoints;
|
||||
}
|
||||
|
||||
var skills = new SkillIDArray8();
|
||||
for (var i = 0; i < entry.Build.Skills.Count && i < 8; i++)
|
||||
{
|
||||
skills[i] = (GWCA.GW.Constants.SkillID)entry.Build.Skills[i];
|
||||
}
|
||||
|
||||
var skillTemplate = new SkillTemplate
|
||||
{
|
||||
Primary = (GWCA.GW.Constants.Profession)entry.Build.Primary,
|
||||
Secondary = (GWCA.GW.Constants.Profession)entry.Build.Secondary,
|
||||
AttributesCount = (uint)entry.Build.Attributes.Count,
|
||||
AttributeIds = attributeIds,
|
||||
Skills = skills
|
||||
};
|
||||
Unsafe.CopyBlock(skillTemplate.AttributeValues, Unsafe.AsPointer(ref attributeValues), (uint)(12 * sizeof(uint)));
|
||||
scopedLogger.LogInformation("Applying build for agent {agentId} with primary {primary} and secondary {secondary}", agentId, entry.Build.Primary, entry.Build.Secondary);
|
||||
this.skillbarContextService.LoadBuild(agentId, &skillTemplate);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private unsafe List<(uint AgentId, Shared.Models.Api.HeroBehavior Behavior)>? GetHeroBehaviorSetup(PartyLoadout partyLoadout)
|
||||
{
|
||||
var scopedLogger = this.logger.CreateScopedLogger();
|
||||
scopedLogger.LogDebug("Getting hero behavior setup");
|
||||
|
||||
if (!this.gameContextService.GetGameContext().TryGetPlayerParty(out _, out _, out var heroes, out _) ||
|
||||
!heroes.HasValue)
|
||||
{
|
||||
scopedLogger.LogError("Failed to get player party");
|
||||
return default;
|
||||
}
|
||||
|
||||
return heroes.Value.AsValueEnumerable()
|
||||
.Select(h =>
|
||||
{
|
||||
if (h.HeroId is API.Interop.GWCA.GW.Constants.HeroID.NoHero)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
var entry = partyLoadout.Entries.AsValueEnumerable().FirstOrDefault(e => e.HeroId == (int)h.HeroId);
|
||||
if (entry is null)
|
||||
{
|
||||
scopedLogger.LogWarning("No entry found for hero {heroId}", h.HeroId);
|
||||
return default;
|
||||
}
|
||||
|
||||
return (h.AgentId, entry.HeroBehavior);
|
||||
})
|
||||
.Where(t => t.AgentId is not 0)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<string?> HeroCommanderFrameLabel(uint heroAgentId, CancellationToken cancellationToken)
|
||||
{
|
||||
var heroOffset = await this.GetHeroNumber(heroAgentId, cancellationToken);
|
||||
if (heroOffset > 6)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
return heroOffset switch
|
||||
{
|
||||
0 => "AgentCommander0",
|
||||
1 => "AgentCommander1",
|
||||
2 => "AgentCommander2",
|
||||
3 => "AgentCommander3",
|
||||
4 => "AgentCommander4",
|
||||
5 => "AgentCommander5",
|
||||
6 => "AgentCommander6",
|
||||
_ => default
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<uint?> GetHeroNumber(uint agentId, CancellationToken cancellationToken)
|
||||
{
|
||||
var scopedLogger = this.logger.CreateScopedLogger();
|
||||
var heroNumber = await this.gameThreadService.QueueOnGameThread(() =>
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
var gameContext = this.gameContextService.GetGameContext();
|
||||
if (gameContext.IsNull)
|
||||
{
|
||||
scopedLogger.LogError("Game context is null");
|
||||
return -1;
|
||||
}
|
||||
|
||||
var charContext = gameContext.Pointer->Character;
|
||||
if (charContext is null)
|
||||
{
|
||||
scopedLogger.LogError("Char context is null");
|
||||
return -1;
|
||||
}
|
||||
|
||||
var playerParty = GWCA.GW.PartyMgr.GetPartyInfo(0);
|
||||
var playerId = charContext->PlayerNumber;
|
||||
var offset = 0;
|
||||
foreach(var hero in playerParty->Heroes)
|
||||
{
|
||||
if (hero.OwnerPlayerId == playerId)
|
||||
{
|
||||
if (hero.AgentId == agentId)
|
||||
{
|
||||
return offset;
|
||||
}
|
||||
|
||||
offset++;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
}, cancellationToken);
|
||||
|
||||
return heroNumber is -1 ? default : (uint)heroNumber;
|
||||
}
|
||||
|
||||
private static BuildEntry? GetBuildEntryById(ProfessionState professionContext, SkillbarData skillbar, PartyAttribute attributes)
|
||||
{
|
||||
return new BuildEntry(
|
||||
Primary: (int)professionContext.Primary,
|
||||
Secondary: (int)professionContext.Secondary,
|
||||
Attributes: attributes.Attribute.GetAttributeEntryList().ToList(),
|
||||
Skills:
|
||||
[
|
||||
(uint)skillbar.Skills[0].SkillId,
|
||||
(uint)skillbar.Skills[1].SkillId,
|
||||
(uint)skillbar.Skills[2].SkillId,
|
||||
(uint)skillbar.Skills[3].SkillId,
|
||||
(uint)skillbar.Skills[4].SkillId,
|
||||
(uint)skillbar.Skills[5].SkillId,
|
||||
(uint)skillbar.Skills[6].SkillId,
|
||||
(uint)skillbar.Skills[7].SkillId
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using Daybreak.API.Interop;
|
||||
using Daybreak.API.Interop.GuildWars;
|
||||
using Daybreak.API.Services.Interop;
|
||||
using System.Extensions.Core;
|
||||
|
||||
namespace Daybreak.API.Services;
|
||||
|
||||
public sealed class UIService(
|
||||
GameThreadService gameThreadService,
|
||||
UIContextService uIContextService,
|
||||
ILogger<UIService> logger)
|
||||
{
|
||||
private readonly GameThreadService gameThreadService = gameThreadService;
|
||||
private readonly UIContextService uIContextService = uIContextService;
|
||||
private readonly ILogger<UIService> logger = logger;
|
||||
|
||||
public async Task<bool> Keypress(ControlAction action, WrappedPointer<Frame> frame, CancellationToken cancellationToken)
|
||||
{
|
||||
var scopedLogger = this.logger.CreateScopedLogger();
|
||||
var keyDownResult = await this.gameThreadService.QueueOnGameThread(() =>
|
||||
{
|
||||
if (!this.uIContextService.KeyDown((GWCA.GW.UI.ControlAction)action, frame))
|
||||
{
|
||||
scopedLogger.LogError("Failed to send keydown action {action}", action);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, cancellationToken);
|
||||
|
||||
if (!keyDownResult)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var keyUpResult = await this.gameThreadService.QueueOnGameThread(() =>
|
||||
{
|
||||
if (!this.uIContextService.KeyUp((GWCA.GW.UI.ControlAction)action, frame))
|
||||
{
|
||||
scopedLogger.LogError("Failed to send keyup action {action}", action);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, cancellationToken);
|
||||
|
||||
if (!keyUpResult)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<ManagedFrame?> GetManagedFrame(string label, CancellationToken cancellationToken)
|
||||
{
|
||||
var scopedLogger = this.logger.CreateScopedLogger();
|
||||
if (string.IsNullOrEmpty(label))
|
||||
{
|
||||
scopedLogger.LogError("Frame label is null or empty");
|
||||
return null;
|
||||
}
|
||||
|
||||
var frame = await this.gameThreadService.QueueOnGameThread(() => this.uIContextService.GetFrameByLabel(label), cancellationToken);
|
||||
if (!frame.IsNull)
|
||||
{
|
||||
// Frame was already open, we do not want to auto-close it
|
||||
scopedLogger.LogInformation("Frame with label {label} is already open", label);
|
||||
return new ManagedFrame(frame, () => { });
|
||||
}
|
||||
|
||||
var uiAction = GetUIActionFromFrameLabel(label);
|
||||
await this.Keypress(uiAction, null, cancellationToken);
|
||||
frame = await this.gameThreadService.QueueOnGameThread(() => this.uIContextService.GetFrameByLabel(label), cancellationToken);
|
||||
await this.gameThreadService.QueueOnGameThread(() => this.uIContextService.SetFrameVisible(frame, true), cancellationToken);
|
||||
return new ManagedFrame(frame, async () =>
|
||||
{
|
||||
await this.gameThreadService.QueueOnGameThread(async () =>
|
||||
{
|
||||
if (!this.uIContextService.GetFrameByLabel(label).IsNull)
|
||||
{
|
||||
await this.Keypress(uiAction, null, CancellationToken.None);
|
||||
}
|
||||
}, CancellationToken.None);
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<string?> DecodeString(string encoded, GWCA.GW.Constants.Language language, CancellationToken cancellationToken)
|
||||
{
|
||||
// GWCA's AsyncDecodeStr handles language switching internally (sets language, decodes, restores)
|
||||
return await this.uIContextService.AsyncDecodeStringAsync(encoded, language, cancellationToken);
|
||||
}
|
||||
|
||||
private static ControlAction GetUIActionFromFrameLabel(string frameLabel)
|
||||
{
|
||||
return frameLabel switch
|
||||
{
|
||||
"AgentCommander0" => ControlAction.ControlAction_OpenHeroCommander1,
|
||||
"AgentCommander1" => ControlAction.ControlAction_OpenHeroCommander2,
|
||||
"AgentCommander2" => ControlAction.ControlAction_OpenHeroCommander3,
|
||||
"AgentCommander3" => ControlAction.ControlAction_OpenHeroCommander4,
|
||||
"AgentCommander4" => ControlAction.ControlAction_OpenHeroCommander5,
|
||||
"AgentCommander5" => ControlAction.ControlAction_OpenHeroCommander6,
|
||||
"AgentCommander6" => ControlAction.ControlAction_OpenHeroCommander7,
|
||||
_ => ControlAction.ControlAction_None
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using Daybreak.API.Services.Interop;
|
||||
using Daybreak.Shared.Services.BuildTemplates;
|
||||
using Daybreak.Shared.Services.BuildTemplates.Parsers;
|
||||
|
||||
namespace Daybreak.API.Services;
|
||||
|
||||
public static class WebApplicationBuilderExtensions
|
||||
{
|
||||
public static WebApplicationBuilder WithDaybreakServices(this WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.AddSingleton<ITemplateParser, LegacySkillTemplateParser>();
|
||||
builder.Services.AddSingleton<ITemplateParser, SkillTemplateParser>();
|
||||
builder.Services.AddSingleton<ITemplateParser, PartyLoadoutTemplateParser>();
|
||||
builder.Services.AddSingleton<IBuildTemplateManager, BuildTemplateManager>();
|
||||
builder.Services.AddSingleton<MemoryScanningService>();
|
||||
builder.Services.AddSingleton<ChatService>();
|
||||
builder.Services.AddSingleton<MainPlayerService>();
|
||||
builder.Services.AddSingleton<CharacterSelectService>();
|
||||
builder.Services.AddSingleton<PartyService>();
|
||||
builder.Services.AddSingleton<UIService>();
|
||||
builder.Services.AddSingleton<LoginService>();
|
||||
builder.Services.AddSingleton<InventoryService>();
|
||||
builder.Services.AddSingleton<GameThreadService>();
|
||||
builder.Services.AddSingleton<SkillbarContextService>();
|
||||
builder.Services.AddSingleton<GameContextService>();
|
||||
builder.Services.AddSingleton<InstanceContextService>();
|
||||
builder.Services.AddSingleton<AgentContextService>();
|
||||
builder.Services.AddSingleton<PlatformContextService>();
|
||||
builder.Services.AddSingleton<PartyContextService>();
|
||||
builder.Services.AddSingleton<PreferencesService>();
|
||||
builder.Services.AddSingleton<UIContextService>();
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Microsoft.AspNetCore.Routing.Constraints;
|
||||
|
||||
namespace Daybreak.API.Swagger;
|
||||
|
||||
public static class WebApplicationBuilderExtensions
|
||||
{
|
||||
public static WebApplicationBuilder WithSwagger(this WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.Configure<RouteOptions>(static options =>
|
||||
{
|
||||
options.SetParameterPolicy<RegexInlineRouteConstraint>("regex");
|
||||
});
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using Scalar.AspNetCore;
|
||||
|
||||
namespace Daybreak.API.Swagger;
|
||||
|
||||
public static class WebApplicationExtensions
|
||||
{
|
||||
public static WebApplication UseSwaggerWithUI(this WebApplication app)
|
||||
{
|
||||
app.MapOpenApi();
|
||||
app.MapScalarApiReference();
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using Net.Sdk.Web.Websockets;
|
||||
using System.Buffers;
|
||||
using System.Net.WebSockets;
|
||||
|
||||
namespace Daybreak.API.WebSockets;
|
||||
|
||||
public abstract class UpdateWebSocketRoute
|
||||
: WebSocketRouteBase
|
||||
{
|
||||
private readonly byte[] sharedBuffer;
|
||||
private readonly ArraySegment<byte> packetBuffer;
|
||||
private int sendingOperations = 0;
|
||||
|
||||
protected abstract int BufferSize { get; }
|
||||
|
||||
public UpdateWebSocketRoute()
|
||||
{
|
||||
this.sharedBuffer = ArrayPool<byte>.Shared.Rent(this.BufferSize);
|
||||
this.packetBuffer = new ArraySegment<byte>(this.sharedBuffer);
|
||||
}
|
||||
|
||||
public override Task SocketClosed()
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(this.sharedBuffer);
|
||||
return base.SocketClosed();
|
||||
}
|
||||
|
||||
public void SendUpdate(ReadOnlySpan<byte> state)
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref this.sendingOperations, 1, 0) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.WebSocket is null)
|
||||
{
|
||||
this.sendingOperations = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
state.CopyTo(this.packetBuffer);
|
||||
var packet = this.packetBuffer.Slice(0, state.Length);
|
||||
this.WebSocket?.SendAsync(packet, WebSocketMessageType.Binary, true, this.Context?.RequestAborted ?? CancellationToken.None)
|
||||
.ContinueWith(_ => this.sendingOperations = 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Daybreak.API.WebSockets;
|
||||
|
||||
public static class WebApplicationBuilderExtensions
|
||||
{
|
||||
public static WebApplicationBuilder WithWebSocketRoutes(this WebApplicationBuilder builder)
|
||||
{
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace Daybreak.API.WebSockets;
|
||||
|
||||
public static class WebApplicationExtensions
|
||||
{
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code")]
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.")]
|
||||
public static WebApplication UseWebSocketRoutes(this WebApplication app)
|
||||
{
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Directives>
|
||||
<Application>
|
||||
<Assembly Name="Microsoft.Extensions.Primitives">
|
||||
<Type Name="Microsoft.Extensions.Primitives.StringValues" Dynamic="Required All" />
|
||||
</Assembly>
|
||||
</Application>
|
||||
</Directives>
|
||||
@@ -0,0 +1,11 @@
|
||||
using Daybreak.Shared.Attributes;
|
||||
using Daybreak.Shared.Models.Themes;
|
||||
|
||||
namespace Daybreak.Attributes;
|
||||
public sealed class DaybreakThemeOptionsAttribute : OptionValuesFactoryAttribute
|
||||
{
|
||||
public override List<object> ValuesFactory()
|
||||
{
|
||||
return [.. Theme.Themes.Cast<object>()];
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -1,9 +1,11 @@
|
||||
using Daybreak.Attributes;
|
||||
using Daybreak.Shared.Attributes;
|
||||
using Daybreak.Shared.Configuration.Options;
|
||||
|
||||
namespace Daybreak.Configuration.Options;
|
||||
|
||||
[OptionsName(Name = "Ascalon Trade Chat")]
|
||||
[OptionsIgnore]
|
||||
[OptionsSynchronizationIgnore]
|
||||
public sealed class AscalonTradeChatOptions : ITradeChatOptions
|
||||
{
|
||||
public string HttpsUri { get; set; } = "https://ascalon.gwtoolbox.com/";
|
||||
@@ -0,0 +1,13 @@
|
||||
using Daybreak.Shared.Attributes;
|
||||
using Daybreak.Shared.Models;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Daybreak.Configuration.Options;
|
||||
|
||||
[OptionsIgnore]
|
||||
[OptionsName(Name = "Credentials")]
|
||||
internal sealed class CredentialManagerOptions
|
||||
{
|
||||
[JsonPropertyName(nameof(ProtectedLoginCredentials))]
|
||||
public List<ProtectedLoginCredentials> ProtectedLoginCredentials { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using Daybreak.Shared.Attributes;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Daybreak.Configuration.Options;
|
||||
|
||||
[OptionsName(Name = "DSOAL")]
|
||||
[OptionsIgnore]
|
||||
internal sealed class DSOALOptions
|
||||
{
|
||||
[JsonPropertyName(nameof(Enabled))]
|
||||
[OptionName(Name = "Enabled", Description = "If true, the launcher will also launch DSOAL when launching GuildWars")]
|
||||
public bool Enabled { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Daybreak.Shared.Attributes;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Daybreak.Configuration.Options;
|
||||
|
||||
[OptionsName(Name = "DXVK")]
|
||||
[OptionsIgnore]
|
||||
internal sealed class DXVKOptions
|
||||
{
|
||||
[JsonPropertyName(nameof(Version))]
|
||||
[OptionSynchronizationIgnore]
|
||||
[OptionIgnore]
|
||||
public string? Version { get; set; }
|
||||
|
||||
[JsonPropertyName(nameof(Enabled))]
|
||||
[OptionName(Name = "Enabled", Description = "If true, the launcher will also launch DXVK when launching GuildWars")]
|
||||
public bool Enabled { get; set; }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user