Compare commits

...

55 commits

Author SHA1 Message Date
phosit
463a2e83f3
Use QUIC as transport protocoll
Fixes: #7001
2026-08-10 11:18:39 +02:00
Ralph Sennhauser
61bcc38458
Allow use of ngtcp2 via pkconfig
Use gnutls for crypto functions

Signed-off-by: Ralph Sennhauser <ralph.sennhauser@gmail.com>
2026-08-10 11:06:16 +02:00
Jonny McCullagh
1c2dbf2eb0 Add more diverse quotes
Some checks failed
checkrefs / lfscheck (push) Has been cancelled
checkrefs / checkrefs (push) Has been cancelled
lint / cppcheck (push) Has been cancelled
lint / copyright (push) Has been cancelled
lint / jenkinsfiles (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
2026-08-10 10:53:00 +02:00
Ralph Sennhauser
df72c1aad1
Support scroll-rotate in Atlas
Split mouse wheel scroll handling into horizontal and vertical. Keep
vertical scroll for zooming and use horizontal to rotate around the y
axis replicating the behaviour of the main game.

Signed-off-by: Ralph Sennhauser <ralph.sennhauser@gmail.com>
2026-08-10 09:00:07 +02:00
Dunedan
40ab70b804
Inline the pre-commit/action Github action
As the latest version of pre-commit is broken right now
(https://github.com/pre-commit/pre-commit/issues/3737) and the official
pre-commit Github action doesn't allow specifying an older pre-commit
version to use, this inlines the logic of the pre-commit action into our
action.
2026-08-09 07:20:49 +02:00
Stan
f90804c78f
Add macOS hint for game mode.
Some checks failed
checkrefs / lfscheck (push) Has been cancelled
checkrefs / checkrefs (push) Has been cancelled
lint / cppcheck (push) Has been cancelled
lint / copyright (push) Has been cancelled
lint / jenkinsfiles (push) Has been cancelled
pre-commit / build (push) Has been cancelled
2026-08-07 14:09:25 +02:00
phosit
d6bdf51d83
Remove CStr::FromInt64$
Some checks failed
checkrefs / lfscheck (push) Has been cancelled
checkrefs / checkrefs (push) Has been cancelled
lint / cppcheck (push) Has been cancelled
lint / copyright (push) Has been cancelled
lint / jenkinsfiles (push) Has been cancelled
pre-commit / build (push) Has been cancelled
For the usecase it's better to use `fmt::format`.
2026-08-06 18:03:05 +02:00
phosit
ae936e8177
Remove CStr::FromUInt
There is `std::to_string` which does the same. For most uses it's better
to use `fmt::format`.
2026-08-06 18:03:05 +02:00
phosit
06e199b12a
Remove CStr::FromInt
There is `std::to_string` which does the same. For most uses it's better
to use `fmt::format`.
2026-08-06 18:03:05 +02:00
ShadowOfHassen
84567e66c2 Fix catafalque aura typos
Some checks are pending
checkrefs / lfscheck (push) Waiting to run
checkrefs / checkrefs (push) Waiting to run
lint / cppcheck (push) Waiting to run
lint / copyright (push) Waiting to run
lint / jenkinsfiles (push) Waiting to run
pre-commit / build (push) Waiting to run
2026-08-06 13:30:42 +02:00
Vladislav Belov
c4e7364802
Removes unused BuildColor4ub from ModelRenderer
We don't need to calculate shading on CPU since we always use shaders.

Refs: a9c27b412b
2026-08-06 11:32:02 +02:00
Vladislav Belov
867bdf318b
Fixes missing material reference in ModelRenderer
Missed in 18a16aa135
2026-08-06 10:31:16 +02:00
Vladislav Belov
e64a138f92
Fixes outdated state on disabling GPU skinning 2026-08-04 23:06:12 +02:00
Vladislav Belov
4a7b7e7df1
Removes smart pointers from ModelRenderer
We don't need to use pointers because we know lifetimes of all
renderers. And its list statically defined. A new renderer should follow
DOD and use a separate submission list with a separate inplace renderer.
2026-08-04 23:06:10 +02:00
Vladislav Belov
fa90ddc8b6
Refactors ModelRenderer and makes it closer to DOD
Separates submitted models and moder renderer and makes relations
explicit. Now it's clear that we don't need separate ModelRenderer and
ModelVertexRenderer.
2026-08-04 23:06:09 +02:00
Vladislav Belov
764a420797
Merges ModelRenderer and ShaderModelRenderer
We use only shaders to render models so we don't need a separate class.

BatchModelRenderer was renamed in 6bc33fe8bd.
m_Renderer became unused in 0346ba1b18.
2026-08-04 23:06:07 +02:00
Vladislav Belov
8a2bb10827
Renames ShaderModelRenderer instances 2026-08-04 23:06:06 +02:00
Vladislav Belov
af9d102126
Removes legacy logic of reused ModelRendererPtr
It became unused in 4a69e9117e.
2026-08-04 23:06:04 +02:00
Ralph Sennhauser
822092fcb4
Fix set camera tooltip in Atlas player section
Some checks failed
checkrefs / lfscheck (push) Has been cancelled
checkrefs / checkrefs (push) Has been cancelled
lint / cppcheck (push) Has been cancelled
lint / copyright (push) Has been cancelled
lint / jenkinsfiles (push) Has been cancelled
pre-commit / build (push) Has been cancelled
Refs: fe192fe58f
Signed-off-by: Ralph Sennhauser <ralph.sennhauser@gmail.com>
2026-08-04 17:22:21 +02:00
Ralph Sennhauser
6e79dc2f70
Use dynamic events for Cinema Section
Replace static events with dynamic events and name delete path function
more aptly.

Signed-off-by: Ralph Sennhauser <ralph.sennhauser@gmail.com>
2026-08-04 17:04:22 +02:00
Dunedan
6fdcd68172
Update the actions used by the lint workflow
Some checks are pending
checkrefs / lfscheck (push) Waiting to run
checkrefs / checkrefs (push) Waiting to run
lint / cppcheck (push) Waiting to run
lint / copyright (push) Waiting to run
lint / jenkinsfiles (push) Waiting to run
pre-commit / build (push) Waiting to run
2026-08-04 10:09:22 +02:00
Dunedan
e4f4030a5e
Update the actions used by the checkrefs workflow
The workaround for the authentication problems isn't necessary with the
new version of the checkout action anymore.
2026-08-04 10:09:09 +02:00
Ralph Sennhauser
2fb6347125
Delete .already-built file prior to new build
In case the build fails this will allow other Jenkins jobs to restore
the old working state themselves.

Signed-off-by: Ralph Sennhauser <ralph.sennhauser@gmail.com>
2026-08-03 20:54:04 +02:00
phosit
7aa756f6a4
Remove line numbers from cppcheck supression list
Some checks are pending
checkrefs / lfscheck (push) Waiting to run
checkrefs / checkrefs (push) Waiting to run
lint / cppcheck (push) Waiting to run
lint / copyright (push) Waiting to run
lint / jenkinsfiles (push) Waiting to run
pre-commit / build (push) Waiting to run
They have to be changed to ofthen when code gets moved.
2026-08-02 14:03:28 +02:00
phosit
e712601602
PreInitGame and InitGame in the Loader
This makes the frame before the simulation starts less stuttery. Doing
it in the Loader also allows to make the progressbar more acurate.
2026-08-02 14:03:28 +02:00
Dunedan
865ca4cd55
Update the pre-commit hooks
Some checks failed
checkrefs / lfscheck (push) Has been cancelled
checkrefs / checkrefs (push) Has been cancelled
lint / cppcheck (push) Has been cancelled
lint / copyright (push) Has been cancelled
lint / jenkinsfiles (push) Has been cancelled
pre-commit / build (push) Has been cancelled
2026-08-01 16:19:18 +02:00
Dunedan
dcb7cef308
Update Github actions used for pre-commit action 2026-08-01 16:19:03 +02:00
vyordan
71be79791f Use turn_id_t (int32_t) for turn-count across simulation, network and replay
Some checks failed
checkrefs / lfscheck (push) Has been cancelled
checkrefs / checkrefs (push) Has been cancelled
lint / cppcheck (push) Has been cancelled
lint / copyright (push) Has been cancelled
lint / jenkinsfiles (push) Has been cancelled
pre-commit / build (push) Has been cancelled
CSimulation2 used , CSimulation2Impl mixed /, and
CTurnManager used  for the same turn-count concept, forcing a
static_cast<int64_t> at the one place these types already met
(rejoin-test comparison).

Add  (alias for std::int32_t) in SimulationCommand.h and use
it consistently for every turn counter in CSimulation2/Impl,
CTurnManager and its subclasses (CLocalTurnManager,
CReplayTurnManager), CNetServerTurnManager/CNetClientTurnManager, and
the network wire format (m_Turn in CEndCommandBatchMessage,
CSimulationMessage, CSyncCheckMessage, CSyncErrorMessage, and
m_CurrentTurn in CLoadedGameMessage).

Turn *duration* fields (m_TurnLength, m_CommandDelay,
DEFAULT_TURN_LENGTH, COMMAND_DELAY_SP/MP, SetTurnLength,
GetSavedTurnLength return type) are left as u32 — they're milliseconds,
not a counter, and out of scope here.

Remaining turn_id_t vs size_t comparisons use std::cmp_equal or
explicit casts, matching the existing pattern in Simulation2.cpp.

Fixes #8718
2026-07-31 10:34:20 +02:00
Atrik
9ab89b01c2 Add Langbart to art credits
Some checks are pending
checkrefs / lfscheck (push) Waiting to run
checkrefs / checkrefs (push) Waiting to run
lint / cppcheck (push) Waiting to run
lint / copyright (push) Waiting to run
lint / jenkinsfiles (push) Waiting to run
pre-commit / build (push) Waiting to run
2026-07-30 20:54:19 +02:00
Atrik
dcce6666ae Add missing garrison flag to some houses
Art work by @Langbart

Fixes #6911
2026-07-30 20:54:19 +02:00
Atrik
202bdb133a Add Atrik to art credits 2026-07-30 20:54:19 +02:00
Atrik
46981d2b90 Add missing garrison flag to German houses 2026-07-30 20:54:19 +02:00
Ralph Sennhauser
49c887b7ca
Don't nest Paths in Common settings
Doesn't make sense to have the Paths StaticBox inside the Common
settings StaticBox so make it a top-level item as well.

While at it dissolve nested constructors to make the creation of the
widgets more readable.

Signed-off-by: Ralph Sennhauser <ralph.sennhauser@gmail.com>
2026-07-30 18:43:24 +02:00
Atrik
91a37e32f6 Sort units spatially for freehand selection
Some checks are pending
checkrefs / lfscheck (push) Waiting to run
checkrefs / checkrefs (push) Waiting to run
lint / cppcheck (push) Waiting to run
lint / copyright (push) Waiting to run
lint / jenkinsfiles (push) Waiting to run
pre-commit / build (push) Waiting to run
2026-07-30 17:19:39 +02:00
Atrik
139b08fe6d Add tests for SelectionBoxTargeting 2026-07-30 17:19:39 +02:00
Atrik
dc6da217fd Enable using SelectionBox for targeting
Introduce a new  input, a command handler and adapt
UnitAI to handle arrays of targets in a single order
2026-07-30 17:19:39 +02:00
Atrik
e41dd2d3a4 Add Math.clamp utility function 2026-07-30 17:19:39 +02:00
phosit
c7953026dd
Use switchPage in autostart
Autostart used `OpenChildPage` to open the loading page. Because it's
started as child page the program doesn't end when closing it.
2026-07-30 12:36:51 +02:00
Ralph Sennhauser
0340cc9abd
Make NUSpline public members protected
Some checks are pending
checkrefs / lfscheck (push) Waiting to run
checkrefs / checkrefs (push) Waiting to run
lint / cppcheck (push) Waiting to run
lint / copyright (push) Waiting to run
lint / jenkinsfiles (push) Waiting to run
pre-commit / build (push) Waiting to run
Add a getter for MaxDistance and make members protected and inline
initialization.

Signed-off-by: Ralph Sennhauser <ralph.sennhauser@gmail.com>
2026-07-29 18:44:03 +02:00
Ralph Sennhauser
aeeda05433
Prefix NUSpline member variables
To follow coding convention rename the vector Node to m_Nodes,
MaxDistance to m_MaxDistance and NodeCount to m_NodeCount.

Signed-off-by: Ralph Sennhauser <ralph.sennhauser@gmail.com>
2026-07-29 18:44:03 +02:00
Atrik
0da1e0c398 Reveal Gaia wonders to players
Some checks are pending
checkrefs / lfscheck (push) Waiting to run
checkrefs / checkrefs (push) Waiting to run
lint / cppcheck (push) Waiting to run
lint / copyright (push) Waiting to run
lint / jenkinsfiles (push) Waiting to run
pre-commit / build (push) Waiting to run
2026-07-29 18:02:37 +02:00
Atrik
dc1e7179a3 Reveal and notify Wonders being constructed
Fixes #8683
2026-07-29 18:02:37 +02:00
Atrik
b6d9d87ba9 Add a test for ExploreCircle 2026-07-29 18:02:37 +02:00
Atrik
839226d542 Support revealing a small area of the map 2026-07-29 18:02:37 +02:00
Vladislav Belov
4125e5b645
Adds sRGB support to GLES 2026-07-29 01:29:11 +02:00
Vladislav Belov
f781181899
Adds sRGB support for proper lighting
Currently we're incorrectly using sRGB colors as raw inputs for lighting
instead of conversion to linear space. For proper PBR we need linear
values.
2026-07-29 01:29:09 +02:00
Vladislav Belov
e2f5b20922
Fixes GL internal format of textures in compute
In compute shaders load/store operations requires sized formats.
2026-07-28 23:32:23 +02:00
Vladislav Belov
b3165505b6
Fixes possible artifacts with GPU skinning on GL
std140 requires 16 bytes alignment and it makes alignment of uvec2 in
SkinData be equal to 16 bytes instead of 8 bytes.
2026-07-28 18:12:21 +02:00
Itms
15b9a39b00
Rebuild SPIRV shaders on all shader changes
Fixes #9096
2026-07-28 16:26:16 +02:00
Ralph Sennhauser
cf7b8aab39
Preserve Cinema Path selection
Commit 81c57e8a28 added support for adding and removing paths.
Reloading path list stores the current selection but it's unused, assume
the idea was to restore it again.

Signed-off-by: Ralph Sennhauser <ralph.sennhauser@gmail.com>
2026-07-25 17:18:16 +02:00
Ralph Sennhauser
df06b6895e
Don't mix static and dynamic events
Commit 309ed5ef28 used dynamic events
which prevents some other menu items from working properly. So don't mix
static and dynamic events for top window.

Signed-off-by: Ralph Sennhauser <ralph.sennhauser@gmail.com>
2026-07-25 14:05:38 +02:00
phosit
a80446eb59
Use the profiler to profile map generation
Instead of writing the duration to the console it uses
`Engine.ProfileStart` and `Engine.ProfileStop` to profile map
generation.

Refs: #5263
2026-07-25 10:47:49 +02:00
phosit
4122f0bfb3
Use atomic instead of COMPILER_FENCE for Profiler2 2026-07-24 17:22:44 +02:00
Ralph Sennhauser
aae1bbbab2
Use new WX object macro
Added in 2010.

Signed-off-by: Ralph Sennhauser <ralph.sennhauser@gmail.com>
2026-07-24 16:51:58 +02:00
Ralph Sennhauser
8523fa5ab4
Fix Atlas build on macOS Tahoe
Bump wxWidgets, the upstream fix was backported to 3.2.9 with commit
a6fc33b416e2ebc804d80a1ac66021935c434573

Fixes: #8594
Signed-off-by: Ralph Sennhauser <ralph.sennhauser@gmail.com>
2026-07-24 13:14:34 +02:00
189 changed files with 4065 additions and 1425 deletions

View file

@ -11,7 +11,7 @@ jobs:
lfscheck: lfscheck:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- name: Fetch the base branch - name: Fetch the base branch
run: git fetch origin ${{ env.BASE_SHA }} run: git fetch origin ${{ env.BASE_SHA }}
@ -24,27 +24,14 @@ jobs:
env: env:
GIT_LFS_SKIP_SMUDGE: "1" GIT_LFS_SKIP_SMUDGE: "1"
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- uses: actions/setup-python@v5 - uses: actions/setup-python@v7
with: with:
python-version: "3.11" python-version: "3.11"
- name: Add remote fork origin for LFS - name: Add remote fork origin for LFS
run: | run: |
PR_REPO="${{ gitea.event.pull_request.head.repo.full_name || gitea.repository }}" PR_REPO="${{ gitea.event.pull_request.head.repo.full_name || gitea.repository }}"
git remote add ${{ gitea.actor }} https://gitea.wildfiregames.com/${PR_REPO}.git git remote add ${{ gitea.actor }} https://gitea.wildfiregames.com/${PR_REPO}.git
- name: Workaround for authentication problem with LFS
# https://gitea.com/gitea/act_runner/issues/164
run: |
git config --local \
http.${{ gitea.server_url }}/${{ gitea.repository }}.git/info/lfs/objects/.extraheader ''
PR_REPO="${{ gitea.event.pull_request.head.repo.full_name || gitea.repository }}"
EXTRAHEADER="$(git config --get --local http.${{ gitea.server_url }}/.extraheader)"
git config --local \
http.${{ gitea.server_url }}/${PR_REPO}.git/info/lfs/objects/batch.extraheader \
'${EXTRAHEADER}'
git config --local \
http.${{ gitea.server_url }}/${PR_REPO}.git/info/lfs/objects/.extraheader ''
- name: Download necessary LFS assets - name: Download necessary LFS assets
shell: sh {0} shell: sh {0}
run: | run: |

View file

@ -12,7 +12,7 @@ jobs:
cppcheck: cppcheck:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
# cache only works for items in workspace, so configure apt to allow for caching. # cache only works for items in workspace, so configure apt to allow for caching.
- name: Setup apt cache locations - name: Setup apt cache locations
@ -26,7 +26,7 @@ jobs:
- name: Cache apt pkg db and and deb files - name: Cache apt pkg db and and deb files
id: apt-cache id: apt-cache
uses: actions/cache@v4 uses: actions/cache@v6
with: with:
path: | path: |
apt-cache apt-cache
@ -49,11 +49,11 @@ jobs:
copyright: copyright:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
with: with:
fetch-depth: 100 fetch-depth: 100
- uses: actions/setup-python@v5 - uses: actions/setup-python@v7
with: with:
python-version: "3.11" python-version: "3.11"
@ -66,10 +66,10 @@ jobs:
jenkinsfiles: jenkinsfiles:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v7
with: with:
node-version: '20' node-version: '20'

View file

@ -3,22 +3,27 @@ name: pre-commit
on: on:
- push - push
- pull_request - pull_request
env:
PRE_COMMIT_VERSION: 4.6.0
jobs: jobs:
build: pre-commit:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- uses: actions/setup-python@v5 - uses: actions/setup-python@v7
with: with:
python-version: "3.11" python-version: "3.11"
- id: restore-pip-cache - uses: actions/cache@v6
uses: actions/cache/restore@v4
with: with:
key: pip-cache-v1-${{ github.workflow }} key: pip-cache-v1-${{github.workflow}}-${{env.pythonLocation}}-${{env.PRE_COMMIT_VERSION}}
path: ~/.cache/pip path: ~/.cache/pip
- uses: pre-commit/action@v3.0.1 - run: python -m pip install pre-commit=="${{ env.PRE_COMMIT_VERSION }}"
- uses: actions/cache/save@v4 shell: bash
if: steps.restore-pip-cache.outcome == 'success' - run: python -m pip freeze --local
shell: bash
- uses: actions/cache@v6
with: with:
key: pip-cache-v1-${{ github.workflow }} path: ~/.cache/pre-commit
path: ~/.cache/pip key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }}
- run: pre-commit run --show-diff-on-failure --color=always --all-files
shell: bash

View file

@ -34,7 +34,7 @@ repos:
\.patch$ \.patch$
) )
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.9 rev: v0.16.0
hooks: hooks:
- id: ruff-check - id: ruff-check
args: args:
@ -56,7 +56,7 @@ repos:
files: ^binaries/ files: ^binaries/
exclude: (^binaries/data/mods/(mod|public)/art/.*\.xml|\.dae$) exclude: (^binaries/data/mods/(mod|public)/art/.*\.xml|\.dae$)
- repo: https://github.com/scop/pre-commit-shfmt - repo: https://github.com/scop/pre-commit-shfmt
rev: v3.12.0-2 rev: v3.13.1-1
hooks: hooks:
- id: shfmt - id: shfmt
args: args:
@ -67,7 +67,7 @@ repos:
hooks: hooks:
- id: shellcheck - id: shellcheck
- repo: https://github.com/igorshubovych/markdownlint-cli - repo: https://github.com/igorshubovych/markdownlint-cli
rev: v0.45.0 rev: v0.47.0
hooks: hooks:
- id: markdownlint - id: markdownlint
language_version: 22.14.0 language_version: 22.14.0
@ -77,13 +77,13 @@ repos:
^source/third_party/ ^source/third_party/
) )
- repo: https://github.com/adrienverge/yamllint - repo: https://github.com/adrienverge/yamllint
rev: v1.37.1 rev: v1.38.0
hooks: hooks:
- id: yamllint - id: yamllint
args: args:
- --strict - --strict
- repo: https://github.com/eslint/eslint - repo: https://github.com/eslint/eslint
rev: v9.39.2 rev: v10.8.0
hooks: hooks:
- id: eslint - id: eslint
language_version: 22.14.0 language_version: 22.14.0

View file

@ -78,6 +78,9 @@
<optional> <optional>
<attribute name="define"/> <attribute name="define"/>
</optional> </optional>
<optional>
<attribute name="srgb"/>
</optional>
</element> </element>
</zeroOrMore> </zeroOrMore>
</interleave> </interleave>

View file

@ -12,10 +12,10 @@
layout(set = 2, binding = LOCATION, FORMAT) uniform image2D NAME layout(set = 2, binding = LOCATION, FORMAT) uniform image2D NAME
#if USE_DESCRIPTOR_INDEXING #if USE_DESCRIPTOR_INDEXING
#define STORAGE_BUFFER(LOCATION) \ #define STORAGE_BUFFER(LOCATION) \
layout(set = 2, binding = LOCATION) layout(std430, set = 2, binding = LOCATION)
#else #else
#define STORAGE_BUFFER(LOCATION) \ #define STORAGE_BUFFER(LOCATION) \
layout(set = 1, binding = LOCATION) layout(std430, set = 1, binding = LOCATION)
#endif #endif
#else #else
// We use offset to the binding slot for OpenGL to avoid overlapping with other // We use offset to the binding slot for OpenGL to avoid overlapping with other
@ -23,7 +23,7 @@
#define STORAGE_2D(LOCATION, FORMAT, NAME) \ #define STORAGE_2D(LOCATION, FORMAT, NAME) \
layout(binding = LOCATION, FORMAT) uniform image2D NAME layout(binding = LOCATION, FORMAT) uniform image2D NAME
#define STORAGE_BUFFER(LOCATION) \ #define STORAGE_BUFFER(LOCATION) \
layout(binding = LOCATION) layout(std430, binding = LOCATION)
#endif #endif
#endif // STAGE_COMPUTE #endif // STAGE_COMPUTE

View file

@ -51,6 +51,18 @@
</textures> </textures>
</variant> </variant>
</group> </group>
<group>
<variant name="ungarrisoned" frequency="1" />
<variant name="garrisoned">
<props>
<prop
actor="props/special/common/garrison_flag_achaemenids.xml"
attachpoint="garrisoned"
selectable="false"
/>
</props>
</variant>
</group>
<group> <group>
<variant name="alive" frequency="1"/> <variant name="alive" frequency="1"/>
<variant file="structures/destruction_small.xml"/> <variant file="structures/destruction_small.xml"/>

View file

@ -33,6 +33,18 @@
</textures> </textures>
</variant> </variant>
</group> </group>
<group>
<variant frequency="1" name="ungarrisoned" />
<variant name="garrisoned">
<props>
<prop
actor="props/special/common/garrison_flag_celt.xml"
attachpoint="garrisoned"
selectable="false"
/>
</props>
</variant>
</group>
<group> <group>
<variant name="alive" frequency="1"/> <variant name="alive" frequency="1"/>
<variant file="structures/destruction_small.xml"/> <variant file="structures/destruction_small.xml"/>

View file

@ -53,6 +53,18 @@
</textures> </textures>
</variant> </variant>
</group> </group>
<group>
<variant name="ungarrisoned" frequency="1" />
<variant name="garrisoned">
<props>
<prop
actor="props/special/common/garrison_flag_celt.xml"
attachpoint="garrisoned"
selectable="false"
/>
</props>
</variant>
</group>
<group> <group>
<variant name="alive" frequency="1"/> <variant name="alive" frequency="1"/>
<variant file="structures/destruction_small.xml"/> <variant file="structures/destruction_small.xml"/>

View file

@ -134,6 +134,20 @@
</props> </props>
</variant> </variant>
</group> </group>
<group>
<variant frequency="1" name="ungarrisoned" />
<variant name="garrisoned">
<props>
<prop
actor="props/special/common/garrison_flag_germ.xml"
attachpoint="garrisoned"
selectable="false"
/>
</props>
</variant>
</group>
<group> <group>
<variant name="alive" frequency="1"/> <variant name="alive" frequency="1"/>
<variant file="structures/destruction_small.xml"/> <variant file="structures/destruction_small.xml"/>

View file

@ -106,6 +106,18 @@
</props> </props>
</variant> </variant>
</group> </group>
<group>
<variant name="ungarrisoned" frequency="1" />
<variant name="garrisoned">
<props>
<prop
actor="props/special/common/garrison_flag_iber.xml"
attachpoint="garrisoned"
selectable="false"
/>
</props>
</variant>
</group>
<group> <group>
<variant name="alive" frequency="1"/> <variant name="alive" frequency="1"/>
<variant file="structures/destruction_small.xml"/> <variant file="structures/destruction_small.xml"/>

View file

@ -47,6 +47,18 @@
</textures> </textures>
</variant> </variant>
</group> </group>
<group>
<variant name="ungarrisoned" frequency="1" />
<variant name="garrisoned">
<props>
<prop
actor="props/special/common/garrison_flag_rome.xml"
attachpoint="garrisoned"
selectable="false"
/>
</props>
</variant>
</group>
<group> <group>
<variant name="alive" frequency="1"/> <variant name="alive" frequency="1"/>
<variant file="structures/destruction_small.xml"/> <variant file="structures/destruction_small.xml"/>

View file

@ -43,6 +43,18 @@
</textures> </textures>
</variant> </variant>
</group> </group>
<group>
<variant frequency="1" name="ungarrisoned" />
<variant name="garrisoned">
<props>
<prop
actor="props/special/common/garrison_flag_seleucids.xml"
attachpoint="garrisoned"
selectable="false"
/>
</props>
</variant>
</group>
<group> <group>
<variant name="alive" frequency="1"/> <variant name="alive" frequency="1"/>
<variant file="structures/sele/light_damage.xml"/> <variant file="structures/sele/light_damage.xml"/>

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:995da95e21261ae0a2718baee69d76ae5f6823d21eaefaa708a89651f2099bb0 oid sha256:ed243a229225ec51ee090588a63307cbd2efedecca06f4f1a0922a4d51ee3967
size 586243 size 586427

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d8e022a3ac132a32061583743d1322fc8792cdd6e7be57857547a8ed3cefbed6 oid sha256:3ce649aa943771357d7c19f48294af719c50ca14178c32be60992f17845d685d
size 525300 size 525483

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:21971f20f5567f820e0f3fffdd21015e2a5ffaee309be7b6efc38d3fc56aecd3 oid sha256:d1d867c2e9f3db233847da547c466325ba9f0a9e7478175281f3538cbb694709
size 503936 size 504112

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3c37b1b7f12a429cd07873d0fdb7cc8692f6578b3e5320a6be0e68420fb4688e oid sha256:cc9c95992889a4c2930a39bd20f7c86b73f7c7ddb3b36492650afae2ae424641
size 780868 size 781051

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1230a92373c4e4684f791594efb35bb7d6c091377a11bd8f0daafd13816d04ec oid sha256:6779d6ca2067d6dd5594b3d2e5ef82d08de97ad058ce7084eb0bf8ae3290f4c2
size 56755 size 56953

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:30a395c3b5a50214a689abe968ad15edb60212c3ea14041a2edc07295a4462c6 oid sha256:9e6aca91d66df31b5820f39025c57937ce6a1593199b8a068722fdec5b0675f6
size 45317 size 45523

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ce554ad452cf75bd4dbb9da5de9830309a1dccf72f72f6a74475078af7f0e1c4 oid sha256:07013561c7e46b020acbd03d4f371300c38d3e364c99a1e7bbcf7467aa8a34e6
size 42851 size 43041

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:60f0614c1092395065c4ba8fe6bed64da873d38c579c9cbc400e9a9b8f30f3a8 oid sha256:1416efa429cd64471d50e969c43ee723382c031c676414433f8fcd58176cfa77
size 272099 size 272274

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c6e7d6264d7b3e03466bf1de58ab366a023120c6f78142aebc6570218a4803d9 oid sha256:9f9e6debd5f2a9659e19e74b5826d3488167edb9a897ae1ed6e4db10742d94e8
size 274339 size 274543

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0624fc07116aa70c14527457fced3afcf7b36df1535bbf10250455e6254aeed6 oid sha256:091d68b7800d9141e87ba8aaf0fa93c5bc7d260dd459774075de610ca34855e2
size 372821 size 372997

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6ef199b28b9d9ef19e0817b189d9e6f1e2f9861ca211890b8d714f9fe05f5c61 oid sha256:c1413de5371443f1249d87fa40a821c880c749513a0347d9128a99472bec7b33
size 420601 size 420808

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:adf219c08e65bd7a149726d325b6f2b4fd67effc3dd1bc211c3163774ac8308e oid sha256:8aba3f3dc5a909ddadd9af5e7abfe9094f55ed6c97b39e7f97b09dc1cb951f4d
size 201143 size 201315

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f8c688df60c4880dc92b6898728d4cef443c86831a2a2e87e61dd44661b58240 oid sha256:0c3298efebf687a07d4693d9014c51e1e870e95fb9d9024c9126b40adfec75a5
size 205000 size 205172

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9d86be00b7e911108e6666422a56eb061a996e6cf0bfe489d6c99f84be752444 oid sha256:b97f6dff13f4639e746e1ce79c120169bccb2e09d4507dc900075cf85e788590
size 216525 size 216697

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5e258d0b3a7b5c3fe2275c2816c636b7478ee1051df31a2cbe2f516465c692f9 oid sha256:8d764a2d5a712f53f08560504f6841ed00b5268fea2a283d2d5254d57ce1df76
size 239166 size 239339

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:82ff53473c25d6e17aeb97f795b543df3ec1c423e8ecb8398d0d014beac57f4e oid sha256:dccc1521e6e626e75ac2f05d2c8b2c2963ee751da08a9e819e4c28954b19f01a
size 101895 size 102099

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:2377bddedc6251acbe8f03bf1820d368b21cbf88fc2783695ff31036af8ed4e3 oid sha256:ae1042461639bca3d3d73fb3e723ca404c8a897ee0e2ad417af1eebb5849d23f
size 82812 size 83027

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b5f1530adecb42af05b6b81719a5c4d00fa06a5eff33f50eda8312ce2e0fcd5f oid sha256:b71b8848a0a2e503adbff9c1e09c06c1e76e16537f38af5b35395a04f11b365a
size 77866 size 78066

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c393e007e24473c1332c7d0c3a8093e0b542974da00b51b853ee413e45818ade oid sha256:256b3864edc754955814d5eec113e73fded49525d2e855a1171a4e4b495ffcc4
size 91109 size 91304

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e8fb0f356f48284704d81b601c7947f32e91995757e8c2fc390ecaf772fbd950 oid sha256:4ef0415cce15f4d1ff4e342c23a995ee5081d21b7f84eb6c19fb89c1d63f84a7
size 84604 size 84789

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:7c2b0d0a461535e0802d2a07395afb4546515ef4d3ccc3eab18d55a0993bc165 oid sha256:2ab540ad518de662ba9a4dbe4d0d0590b6ee0b771bc482073296c949ee33d571
size 184407 size 184610

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:828e2e63b9686a601b3641be10f26e3b099ced5b8dc8d453ab94d41270f22b39 oid sha256:39a6a575b8bbb0f82ed8cf4056e0df932a59fd6e3343005625b3b76644c6879c
size 213385 size 213561

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4825c81a245ddae5637f3b2dee78f7749e27a48d2efa244bc95af075ddabcd1a oid sha256:93341f17ad185e28ce62a533f1db457aec4b69bb2f67c98e4b89e4bcf9503b6a
size 144598 size 144784

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:427f9512af696afaae0721374a86ceb0e1432602013cb3dde818bfcd2791c163 oid sha256:dc27dba302816891bb8f33bc65d9d68b2b330f946256aa966e5d684714af9fe2
size 212264 size 212447

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:19ca2828e5e6b99065d3d901c1a90f1b73df065dc94b3a4b76b4e5804890c137 oid sha256:270fe9095c3894fb448cb91668ad48d886088006c689f0efa148e8a78a3de043
size 349847 size 350053

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:57039fff36fbba71de79b89614fea988a98bdc85b2bdc5ef88ce3a632da95a55 oid sha256:977b2ffb5dc9cf0364d125f0b3dd17e1fb0cd482b5c96fbf7c61b6e6a030db86
size 340693 size 340906

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:221eeca4ee2e46023d346e80c7a3b4b3852699e0c193e31fa58e0619e858f7a2 oid sha256:f177172ef923455050064af60377f2e1b171ff4ec72fa9aa5fdc5c4c28000550
size 272252 size 272435

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:afc01d8e7e7efc988b2f78b58cfb6b48b5453b8786609e9ce5db38bc617a7984 oid sha256:acdaaf09848dc5cb7cf207c7bb535865aa4199a5a50609ca3b632ce5d88d95a2
size 279942 size 280139

View file

@ -330,3 +330,20 @@ Math.euclidDistance3D = function(x1, y1, z1, x2, y2, z2)
{ {
return Math.sqrt(Math.euclidDistance3DSquared(x1, y1, z1, x2, y2, z2)); return Math.sqrt(Math.euclidDistance3DSquared(x1, y1, z1, x2, y2, z2));
}; };
/**
* Clamps a value between a minimum and maximum range.
*
* @param {number} value - The value to clamp
* @param {number} min - Minimum allowed value
* @param {number} max - Maximum allowed value
* @returns {number} The clamped value (min <= result <= max)
*/
Math.clamp = function(value, min, max)
{
if (value < min)
return min;
if (value > max)
return max;
return value;
};

View file

@ -442,3 +442,17 @@ Vector3D.div = function(v, f)
{ {
return new Vector3D(v.x / f, v.y / f, v.z / f); return new Vector3D(v.x / f, v.y / f, v.z / f);
}; };
/**
* Linear interpolation between this vector and another.
* @param {Vector2D} v - The target vector.
* @param {number} t - Interpolation factor (0 = this, 1 = v).
* @returns {Vector2D} A new vector at the interpolated position.
*/
Vector2D.prototype.lerp = function(v, t)
{
return new Vector2D(
this.x + (v.x - this.x) * t,
this.y + (v.y - this.y) * t
);
};

View file

@ -72,6 +72,7 @@
"List": [ "List": [
{ "nick": "Alexandermb", "name": "Daniel Morgado" }, { "nick": "Alexandermb", "name": "Daniel Morgado" },
{ "nick": "Athos" }, { "nick": "Athos" },
{ "nick": "Atrik" },
{ "nick": "Basshunter", "name": "Sebastián Gómez" }, { "nick": "Basshunter", "name": "Sebastián Gómez" },
{ "nick": "BigTiger", "name": "Nathanael P. Moore" }, { "nick": "BigTiger", "name": "Nathanael P. Moore" },
{ "nick": "Brightgalrs", "name": "Robert D. Schultz" }, { "nick": "Brightgalrs", "name": "Robert D. Schultz" },
@ -80,6 +81,7 @@
{ "nick": "Enrique", "name": "Enrique Keykens Espolio" }, { "nick": "Enrique", "name": "Enrique Keykens Espolio" },
{ "nick": "Gen.Kenobi", "name": "Daniel Schubert" }, { "nick": "Gen.Kenobi", "name": "Daniel Schubert" },
{ "nick": "Historicity", "name": "Shane" }, { "nick": "Historicity", "name": "Shane" },
{ "nick": "Langbart" },
{ "nick": "Leyto", "name": "Valentin Levalet" }, { "nick": "Leyto", "name": "Valentin Levalet" },
{ "nick": "LordGood", "name": "Johnathan B. Good" }, { "nick": "LordGood", "name": "Johnathan B. Good" },
{ "nick": "m7600" }, { "nick": "m7600" },

View file

@ -507,6 +507,49 @@ var unitFilters = {
} }
}; };
// Choose, inside a list of entities, which ones are targetable foes for boxtargeting.
// We also filter out entities that aren't visible, or low priority like livestocks
function getTargetableEntities(ents)
{
const player = g_ViewedPlayer;
const simState = GetSimState();
const isEnemy = simState.players[player].isEnemy;
// Filter valid target candidates
const candidates = ents.filter(entity =>
{
const entState = GetEntityState(entity);
return entState &&
unitFilters.isUnit(entity) &&
entState.visibility != "hidden";
});
// Enemy players
const enemyPlayers = candidates.filter(entity =>
{
const entState = GetEntityState(entity);
return isEnemy[entState.player] && entState.player !== 0 && !hasClass(entState, "Domestic");
});
if (enemyPlayers.length > 0)
return enemyPlayers;
// Gaia
const gaiaUnits = candidates.filter(entity =>
{
const entState = GetEntityState(entity);
return entState.player === 0;
});
if (gaiaUnits.length > 0)
return gaiaUnits;
// Domestic animals
return candidates.filter(entity =>
{
const entState = GetEntityState(entity);
return hasClass(entState, "Domestic");
});
}
// Choose, inside a list of entities, which ones will be selected. // Choose, inside a list of entities, which ones will be selected.
// We may use several entity filters, until one returns at least one element. // We may use several entity filters, until one returns at least one element.
function getPreferredEntities(ents) function getPreferredEntities(ents)
@ -572,16 +615,19 @@ function handleInputBeforeGui(ev, hoveredObject)
case INPUT_BANDBOXING: case INPUT_BANDBOXING:
{ {
const bandbox = Engine.GetGUIObjectByName("bandbox"); const bandbox = Engine.GetGUIObjectByName("bandbox");
const isAttackMode = Engine.HotkeyIsPressed("session.attack") && !g_IsObserver;
switch (ev.type) switch (ev.type)
{ {
case "mousemotion": case "mousemotion":
{ {
const rect = updateBandbox(bandbox, ev, false); const rect = updateBandbox(bandbox, ev, false);
const ents = Engine.PickPlayerEntitiesInRect(rect[0], rect[1], rect[2], rect[3], g_ViewedPlayer); const player = isAttackMode ? -1 : g_ViewedPlayer;
const preferredEntities = getPreferredEntities(ents);
g_Selection.setHighlightList(preferredEntities);
const ents = Engine.PickPlayerEntitiesInRect(rect[0], rect[1], rect[2], rect[3], player);
const highlightEntities = isAttackMode ? getTargetableEntities(ents) : getPreferredEntities(ents);
g_Selection.setHighlightList(highlightEntities);
return false; return false;
} }
@ -589,6 +635,30 @@ function handleInputBeforeGui(ev, hoveredObject)
if (ev.button == SDL_BUTTON_LEFT) if (ev.button == SDL_BUTTON_LEFT)
{ {
const rect = updateBandbox(bandbox, ev, true); const rect = updateBandbox(bandbox, ev, true);
if (isAttackMode)
{
// Box attack behavior
const ents = Engine.PickPlayerEntitiesInRect(rect[0], rect[1], rect[2], rect[3], -1);
// Filter to only enemy units
const enemyUnits = getTargetableEntities(ents);
// Get currently selected friendly units
const selectedUnits = g_Selection.toList();
if (selectedUnits.length && enemyUnits.length)
{
// Sort entities by position
const sortedCombattants = sortEntitiesForEngagement(selectedUnits, enemyUnits);
// Distribute attack orders
distributeAttackOrders(sortedCombattants.attackers, sortedCombattants.targets);
}
g_Selection.setHighlightList([]);
inputState = INPUT_NORMAL;
return true;
}
const ents = getPreferredEntities(Engine.PickPlayerEntitiesInRect(rect[0], rect[1], rect[2], rect[3], g_ViewedPlayer)); const ents = getPreferredEntities(Engine.PickPlayerEntitiesInRect(rect[0], rect[1], rect[2], rect[3], g_ViewedPlayer));
g_Selection.setHighlightList([]); g_Selection.setHighlightList([]);
@ -1371,6 +1441,7 @@ function positionUnitsFreehandSelectionMouseUp(ev)
for (let i = 1; i < inputLine.length; ++i) for (let i = 1; i < inputLine.length; ++i)
lengthOfLine += inputLine[i].distanceTo(inputLine[i - 1]); lengthOfLine += inputLine[i].distanceTo(inputLine[i - 1]);
// Filter units that can move
const selection = g_Selection.filter(ent => !!GetEntityState(ent).unitAI).sort((a, b) => a - b); const selection = g_Selection.filter(ent => !!GetEntityState(ent).unitAI).sort((a, b) => a - b);
// Checking the line for a minimum length to save performance. // Checking the line for a minimum length to save performance.
@ -1380,39 +1451,43 @@ function positionUnitsFreehandSelectionMouseUp(ev)
return !!action && doAction(action, ev); return !!action && doAction(action, ev);
} }
// Even distribution of the units on the line. // Sort units by their projection onto the line direction (parallel sorting)
let p0 = inputLine[0]; const sortedSelection = sortEntitiesAlongLine(selection, inputLine[0], inputLine[inputLine.length - 1], true);
let entityDistribution = [p0];
const distanceBetweenEnts = lengthOfLine / (selection.length - 1);
let freeDist = -distanceBetweenEnts;
for (let i = 1; i < inputLine.length; ++i) const selectionCount = sortedSelection.length;
const targetPositions = [];
// Distribute units evenly along the polyline
for (let i = 0; i < selectionCount; ++i)
{ {
const p1 = inputLine[i]; const t = i / (selectionCount - 1);
freeDist += inputLine[i - 1].distanceTo(p1); const targetDistance = t * lengthOfLine;
while (freeDist >= 0) let accumulated = 0;
let targetPoint = inputLine[0];
for (let j = 1; j < inputLine.length; ++j)
{ {
p0 = Vector2D.sub(p0, p1).normalize().mult(freeDist).add(p1); const segStart = inputLine[j - 1];
entityDistribution.push(p0); const segEnd = inputLine[j];
freeDist -= distanceBetweenEnts; const segLength = segEnd.distanceTo(segStart);
if (accumulated + segLength >= targetDistance)
{
// Interpolate within this segment
const remaining = targetDistance - accumulated;
const frac = remaining / segLength;
targetPoint = segStart.lerp(segEnd, frac);
break;
}
accumulated += segLength;
} }
targetPositions.push(targetPoint);
} }
// Rounding errors can lead to missing or too many points.
entityDistribution = entityDistribution.slice(0, selection.length);
entityDistribution = entityDistribution.concat(new Array(selection.length - entityDistribution.length).fill(inputLine[inputLine.length - 1]));
if (Vector2D.from3D(GetEntityState(selection[0]).position).distanceTo(entityDistribution[0]) +
Vector2D.from3D(GetEntityState(selection[selection.length - 1]).position).distanceTo(entityDistribution[selection.length - 1]) >
Vector2D.from3D(GetEntityState(selection[0]).position).distanceTo(entityDistribution[selection.length - 1]) +
Vector2D.from3D(GetEntityState(selection[selection.length - 1]).position).distanceTo(entityDistribution[0]))
entityDistribution.reverse();
Engine.PostNetworkCommand({ Engine.PostNetworkCommand({
"type": isAttackMovePressed() ? "attack-walk-custom" : "walk-custom", "type": isAttackMovePressed() ? "attack-walk-custom" : "walk-custom",
"entities": selection, "entities": sortedSelection,
"targetPositions": entityDistribution.map(pos => pos.toFixed(2)), "targetPositions": targetPositions.map(pos => pos.toFixed(2)),
"targetClasses": Engine.HotkeyIsPressed("session.attackmoveUnit") ? { "attack": ["Unit"] } : { "attack": ["Unit", "Structure"] }, "targetClasses": Engine.HotkeyIsPressed("session.attackmoveUnit") ? { "attack": ["Unit"] } : { "attack": ["Unit", "Structure"] },
"queued": Engine.HotkeyIsPressed("session.queue"), "queued": Engine.HotkeyIsPressed("session.queue"),
"pushFront": Engine.HotkeyIsPressed("session.pushorderfront"), "pushFront": Engine.HotkeyIsPressed("session.pushorderfront"),
@ -1420,9 +1495,10 @@ function positionUnitsFreehandSelectionMouseUp(ev)
}); });
// Add target markers with a minimum distance of 5 to each other. // Add target markers with a minimum distance of 5 to each other.
const entitiesBetweenMarker = Math.ceil(5 / distanceBetweenEnts); const stepDistance = lengthOfLine / (selectionCount - 1);
for (let i = 0; i < entityDistribution.length; i += entitiesBetweenMarker) const entitiesBetweenMarker = Math.max(1, Math.ceil(5 / stepDistance));
DrawTargetMarker({ "x": entityDistribution[i].x, "z": entityDistribution[i].y }); for (let i = 0; i < targetPositions.length; i += entitiesBetweenMarker)
DrawTargetMarker({ "x": targetPositions[i].x, "z": targetPositions[i].y });
Engine.GuiInterfaceCall("PlaySound", { Engine.GuiInterfaceCall("PlaySound", {
"name": "order_walk", "name": "order_walk",
@ -1929,3 +2005,108 @@ function clearSelection()
g_Selection.reset(); g_Selection.reset();
preSelectedAction = ACTION_NONE; preSelectedAction = ACTION_NONE;
} }
function distributeAttackOrders(attackers, targets)
{
if (targets.length < 1)
return;
// Play attack sounds from a sample of units in selection
const soundCount = Math.min(3, attackers.length);
for (let i = 0; i < soundCount; i++)
{
const t = soundCount === 1 ? 0 : i / (soundCount - 1);
const attackerIndex = Math.floor(t * (attackers.length - 1));
setTimeout(() =>
{
Engine.GuiInterfaceCall("PlaySound", {
"name": "order_attack",
"entity": attackers[attackerIndex]
});
}, i * 180);
}
Engine.PostNetworkCommand({
"type": "attack-group",
"entities": attackers,
"targets": targets,
"queued": Engine.HotkeyIsPressed("session.queue"),
"allowCapture": false
});
}
/**
* Sorts attackers and targets for left-to-right engagement across the battle line.
*
* Entities are sorted by their perpendicular distance to the line connecting the
* two army centers, creating natural pairings across the battle front.
*
* @param {number[]} attackers - Array of attacking entity IDs.
* @param {number[]} targets - Array of target entity IDs.
* @returns {Object} { attackers: number[], targets: number[] } - Both arrays sorted
* for cross-line engagement (targets automatically reversed).
*/
function sortEntitiesForEngagement(attackers, targets)
{
const getPosition = (id) => GetEntityState(id).position;
const computeAveragePosition = (entities) =>
{
if (entities.length === 0) return new Vector2D(0, 0);
const vectors = entities.map(id => Vector2D.from3D(getPosition(id)));
return Vector2D.average(vectors);
};
// Calculate the axis of engagement - the line between the groups' average positions
const avgAttackers = computeAveragePosition(attackers);
const avgTargets = computeAveragePosition(targets);
// Sort each group by perpendicular distance to the engagement line
const sortedAttackers = sortEntitiesAlongLine(attackers, avgAttackers, avgTargets);
const sortedTargets = sortEntitiesAlongLine(targets, avgAttackers, avgTargets);
// Reverse targets so the leftmost attacker pairs with leftmost target
return {
"attackers": sortedAttackers,
"targets": sortedTargets.reverse()
};
}
/**
* Sorts entity IDs by their projection onto a line's direction or perpendicular axis.
*
* @param {number[]} entities - Array of entity IDs.
* @param {Vector2D} lineStart - Start point of the reference line.
* @param {Vector2D} lineEnd - End point of the reference line.
* @param {boolean} sortByDirection - If true, sort by parallel projection (along line);
* If false, sort by perpendicular distance (default).
* @returns {number[]} Sorted entity IDs.
*/
function sortEntitiesAlongLine(entities, lineStart, lineEnd, sortByDirection = false)
{
const dir = lineEnd.sub(lineStart);
const length = dir.length();
if (length === 0)
return entities.slice();
// Choose basis vector: either parallel or perpendicular to the line
let basis;
if (sortByDirection)
basis = dir.mult(1 / length); // Unit direction vector
else
basis = new Vector2D(-dir.y, dir.x).normalize(); // Perpendicular
const withCoord = entities.map(id =>
{
const pos = GetEntityState(id).position;
const point2D = Vector2D.from3D(pos);
const rel = point2D.sub(lineStart);
const coord = rel.dot(basis); // Project onto basis vector
return { id, coord };
});
// Sort by that coordinate
withCoord.sort((a, b) => a.coord - b.coord);
return withCoord.map(item => item.id);
}

View file

@ -208,3 +208,13 @@
“This is a woman's resolve; as for men, they may live and be slaves.” \n— Boudicca, rallying against Roman occupation (Tacitus's Annals, Book 14, Chapter 35) “This is a woman's resolve; as for men, they may live and be slaves.” \n— Boudicca, rallying against Roman occupation (Tacitus's Annals, Book 14, Chapter 35)
“There are two things, knowledge and opinion; one makes its possessor know, the other to be ignorant.” \n— (Hippocratic treatise known as “The Law”, part of the Hippocratic Corpus) “There are two things, knowledge and opinion; one makes its possessor know, the other to be ignorant.” \n— (Hippocratic treatise known as “The Law”, part of the Hippocratic Corpus)
“Let them hate me, so long as they fear me.” \n— Caligula (Suetonius, The Twelve Caesars, Book30) “Let them hate me, so long as they fear me.” \n— Caligula (Suetonius, The Twelve Caesars, Book30)
“There is no instance of a nation benefiting from prolonged warfare.” \n— Sun Tzu (“The Art of War”, Chapter 2)
“To subdue the enemy without fighting is the acme of skill.” \n— Sun Tzu (“The Art of War”, Chapter 3)
“One defends when his strength is inadequate; he attacks when it is abundant.” \n— Sun Tzu (“The Art of War”, Chapter 4)
“When torrential water tosses boulders, it is because of its momentum. When the strike of a hawk breaks the body of its prey, it is because of timing.” \n— Sun Tzu (“The Art of War”, Chapter 5)
“Treat your men as you would your own beloved sons. And they will follow you into the deepest valley.” \n— Sun Tzu (“The Art of War”, Chapter 10)
“Woe to the defeated!” \n— Brennus disputing the weight of gold ransom while sacking Rome c. 387 BC (Livy, “Ab Urbe Condita”, Book 5, Chapter 48)
“\[Brutus] found the women fighting and perishing in company with the men with such bravery that they uttered no cry even in the midst of slaughter.” \n— Brutus in Lusitania during the battles with Viriathus (Appian of Alexandria, “The Spanish Wars”, 15)
“What I desire for my own children, I desire for all men.” \n— Ashoka the Great (“The Kalinga Rock Edicts”)
“If you Romans choose to lord it over the world, does it follow that the world is to accept slavery ?” \n— Caratacus pleading for pardon from Roman Emperor Claudius in Rome (Tacitus, “Annals”, 12.37)
“And where am I to get so many soldiers ?” \n— Septimius Severus, frustrated with the challenge of controlling his own men and the harsh realities of frontier warfare against the northern tribes in Britannia. (Cassius Dio, “Roman History”, Vol IX, Book LXXVI, Section 11)

View file

@ -1,6 +1,6 @@
function RandomMapLogger() function RandomMapLogger()
{ {
this.lastTime = undefined; this.taskStarted = false;
this.startTime = Engine.GetMicroseconds ? Engine.GetMicroseconds() : 0; this.startTime = Engine.GetMicroseconds ? Engine.GetMicroseconds() : 0;
this.prefix = ""; // seems noisy this.prefix = ""; // seems noisy
@ -21,27 +21,18 @@ RandomMapLogger.prototype.printDirectly = function(string)
RandomMapLogger.prototype.print = function(string) RandomMapLogger.prototype.print = function(string)
{ {
this.printDuration(); if (this.taskStarted)
this.printDirectly(this.prefix + string + "..."); Engine.ProfileStop();
this.lastTime = Engine.GetMicroseconds();
};
RandomMapLogger.prototype.printDuration = function() const text = this.prefix + string;
{ this.printDirectly(text + "...\n");
if (!this.lastTime) Engine.ProfileStart(text);
return; this.taskStarted = true;
this.printDurationDirectly("", this.lastTime);
this.lastTime = Engine.GetMicroseconds();
}; };
RandomMapLogger.prototype.close = function() RandomMapLogger.prototype.close = function()
{ {
this.printDuration(); if (this.taskStarted)
this.printDurationDirectly(this.prefix + "Total map generation time:", this.startTime); Engine.ProfileStop();
}; this.printDirectly(this.prefix + "Map generation finished.\n");
RandomMapLogger.prototype.printDurationDirectly = function(text, startTime)
{
this.printDirectly(text + " " + ((Engine.GetMicroseconds() - startTime) / 1000000).toFixed(6) + "s.\n");
}; };

View file

@ -603,4 +603,147 @@ TriggerHelper.SpawnAndTurretAtClasses = function(playerID, classes, templates, c
return results; return results;
}; };
/**
* Makes an entity permanently visible to all players by:
* - Setting alwaysVisible flag
* - Deactivating fogging (no mirages)
* - Exploring the surrounding area
* - Forcing a visibility update
*
* @param {number} ent - Entity ID to make permanently visible
*/
TriggerHelper.MakeEntityPermanentlyVisible = function(ent)
{
const cmpVisibility = Engine.QueryInterface(ent, IID_Visibility);
if (cmpVisibility)
{
cmpVisibility.alwaysVisible = true;
cmpVisibility.SetActivated(true);
}
const cmpFogging = Engine.QueryInterface(ent, IID_Fogging);
if (cmpFogging)
cmpFogging.PermanentlyReveal("all");
const cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager);
const cmpPosition = Engine.QueryInterface(ent, IID_Position);
if (cmpRangeManager && cmpPosition && cmpPosition.IsInWorld())
{
const pos = cmpPosition.GetPosition2D();
const numPlayers = Engine.QueryInterface(SYSTEM_ENTITY, IID_PlayerManager).GetNumPlayers();
const exploreRadius = TriggerHelper.CalculateExplorationRadius(ent);
for (let i = 1; i < numPlayers; ++i)
cmpRangeManager.ExploreCircle(i, pos.x, pos.y, exploreRadius);
cmpRangeManager.RequestVisibilityUpdate(ent);
}
};
/**
* Calculate a reasonable exploration radius from an entity's footprint.
* Calculates the radius as the entity's full extent to reveal the surrounding area.
* This is equivalent to (extent/2) * 2, giving a 2x margin around the entity.
*/
TriggerHelper.CalculateExplorationRadius = function(ent)
{
const cmpFootprint = Engine.QueryInterface(ent, IID_Footprint);
if (!cmpFootprint)
return 50;
const shapeInfo = cmpFootprint.GetShape();
let halfExtent; // The radius of the entity (half its size)
if (shapeInfo && shapeInfo.type === "circle")
halfExtent = shapeInfo.radius || shapeInfo.size0;
else if (shapeInfo && shapeInfo.type === "square")
halfExtent = Math.max(shapeInfo.width || shapeInfo.size0, shapeInfo.depth || shapeInfo.size1) / 2;
else
return 50;
// Use 2x the entity's radius to reveal surrounding area
// This gives a comfortable margin around the entity
const radius = halfExtent * 2;
return radius;
};
/**
* Sends a notification to specific players.
*
* @param {string} message - The message to display (use markForTranslation)
* @param {number[]} players - Array of player IDs to send the notification to
* @param {number} duration - Duration in milliseconds
* @param {Object} parameters - Translation parameters (optional)
*/
TriggerHelper.SendNotification = function(message, players, duration = 30000, parameters = {}, translateParameters = [])
{
const cmpGuiInterface = Engine.QueryInterface(SYSTEM_ENTITY, IID_GuiInterface);
if (!cmpGuiInterface)
return;
// Ensure all parameter values are primitive types
const primitiveParameters = {};
for (const key in parameters)
{
const value = parameters[key];
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean")
primitiveParameters[key] = value;
else
primitiveParameters[key] = String(value);
}
cmpGuiInterface.AddTimeNotification(
{
"message": message,
"players": players,
"parameters": primitiveParameters,
"translateMessage": true,
"translateParameters": translateParameters
},
duration
);
};
/**
* Sends a notification to a specific player and a different notification to all other active players.
*
* @param {number} player - The player ID who gets the 'owner' message
* @param {string} ownerMessage - Message for the specified player (use markForTranslation)
* @param {string} othersMessage - Message for all other active players (use markForTranslation)
* @param {number} duration - Duration in milliseconds
* @param {Object} parameters - Translation parameters shared by both messages (optional)
* @param {Object} ownerParameters - Additional parameters for the owner message (optional)
* @param {Object} othersParameters - Additional parameters for the others message (optional)
*/
TriggerHelper.SendDualNotification = function(player, ownerMessage, othersMessage, duration = 30000, parameters = {}, ownerParameters = {}, othersParameters = {})
{
const numPlayers = Engine.QueryInterface(SYSTEM_ENTITY, IID_PlayerManager).GetNumPlayers();
const others = [-1]; // Include observers (player id -1)
for (let playerID = 1; playerID < numPlayers; ++playerID)
{
if (playerID == player)
continue;
const cmpPlayer = QueryPlayerIDInterface(playerID);
if (cmpPlayer && cmpPlayer.GetState() == "defeated")
continue;
others.push(playerID);
}
const mergedOwnerParams = Object.assign({}, parameters, ownerParameters);
const mergedOthersParams = Object.assign({}, parameters, othersParameters);
// Auto-detect translatable parameters (those starting with "_" or named "player")
const translateParameters = Object.keys(mergedOthersParams).filter(key => key.startsWith("_") || key == "player");
TriggerHelper.SendNotification(ownerMessage, [player], duration, mergedOwnerParams, translateParameters);
TriggerHelper.SendNotification(othersMessage, others, duration, mergedOthersParams, translateParameters);
};
TriggerHelper.SendWonderNotifications = function(owner, othersMessage, ownerMessage)
{
TriggerHelper.SendDualNotification(owner, ownerMessage, othersMessage, 30000, { "_player_": owner });
};
Engine.RegisterGlobal("TriggerHelper", TriggerHelper); Engine.RegisterGlobal("TriggerHelper", TriggerHelper);

View file

@ -1,16 +1,42 @@
Trigger.prototype.WonderVictoryEntityRenamed = function(data) Trigger.prototype.WonderVictoryEntityRenamed = function(data)
{ {
if (this.wonderVictoryMessages[data.entity] && Engine.QueryInterface(data.newentity, IID_Wonder)) if (!Engine.QueryInterface(data.newentity, IID_Wonder))
{ return;
// When an entity is renamed, we first create a new entity,
// which in case it is a wonder will receive a timer.
// However on a rename we want to use the timer from the old entity,
// so we need to remove the timer of the new entity.
this.WonderVictoryDeleteTimer(data.newentity);
// Check if wonder victory is enabled
const cmpEndGameManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_EndGameManager);
if (!cmpEndGameManager)
return;
const victoryConditions = cmpEndGameManager.GetVictoryConditions();
if (!victoryConditions || !victoryConditions.includes("wonder"))
return;
// Handle timer transfer (only if old entity had the timer)
if (this.wonderVictoryMessages[data.entity])
{
this.WonderVictoryDeleteTimer(data.newentity);
this.wonderVictoryMessages[data.newentity] = this.wonderVictoryMessages[data.entity]; this.wonderVictoryMessages[data.newentity] = this.wonderVictoryMessages[data.entity];
delete this.wonderVictoryMessages[data.entity]; delete this.wonderVictoryMessages[data.entity];
} }
// Make the completed wonder permanently visible
TriggerHelper.MakeEntityPermanentlyVisible(data.newentity);
// Send "wonder completed" notifications (only for non-Gaia wonders)
const cmpOwnership = Engine.QueryInterface(data.newentity, IID_Ownership);
if (cmpOwnership)
{
const owner = cmpOwnership.GetOwner();
if (owner > 0)
{
TriggerHelper.SendWonderNotifications(
owner,
markForTranslation("%(_player_)s has built a Wonder!"),
markForTranslation("You have built a Wonder!")
);
}
}
}; };
Trigger.prototype.WonderVictoryOwnershipChanged = function(data) Trigger.prototype.WonderVictoryOwnershipChanged = function(data)
@ -18,6 +44,15 @@ Trigger.prototype.WonderVictoryOwnershipChanged = function(data)
if (!Engine.QueryInterface(data.entity, IID_Wonder)) if (!Engine.QueryInterface(data.entity, IID_Wonder))
return; return;
// Check if wonder victory is enabled
const cmpEndGameManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_EndGameManager);
if (!cmpEndGameManager)
return;
const victoryConditions = cmpEndGameManager.GetVictoryConditions();
if (!victoryConditions || !victoryConditions.includes("wonder"))
return;
this.WonderVictoryDeleteTimer(data.entity); this.WonderVictoryDeleteTimer(data.entity);
if (data.to > 0) if (data.to > 0)
@ -146,11 +181,69 @@ Trigger.prototype.WonderVictorySetWinner = function(playerID)
n)); n));
}; };
Trigger.prototype.WonderStartNotification = function(data)
{
// Check if wonder victory is enabled in game settings
const cmpEndGameManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_EndGameManager);
if (!cmpEndGameManager)
return;
// Don't show the wonder foundation if it's not a wonder victory game
const victoryConditions = cmpEndGameManager.GetVictoryConditions();
if (!victoryConditions || !victoryConditions.includes("wonder"))
return;
const cmpTemplateManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_TemplateManager);
if (!cmpTemplateManager)
return;
const template = cmpTemplateManager.GetTemplate(data.template);
if (!template || !("Wonder" in template))
return;
const owner = TriggerHelper.GetOwner(data.foundation);
if (owner <= 0)
return;
TriggerHelper.MakeEntityPermanentlyVisible(data.foundation);
TriggerHelper.SendWonderNotifications(
owner,
markForTranslation("%(_player_)s has started building a Wonder."),
markForTranslation("You have started building a Wonder.")
);
};
Trigger.prototype.RevealGaiaWonders = function()
{
Engine.ProfileStart("RevealGaiaWonders");
const cmpEndGameManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_EndGameManager);
if (!cmpEndGameManager)
return;
const victoryConditions = cmpEndGameManager.GetVictoryConditions();
if (!victoryConditions || !victoryConditions.includes("wonder"))
return;
// Find and reveal all Gaia wonders
const gaiaWonders = Engine.GetEntitiesWithInterface(IID_Wonder).filter(
ent => TriggerHelper.GetOwner(ent) === 0
);
for (const ent of gaiaWonders)
TriggerHelper.MakeEntityPermanentlyVisible(ent);
Engine.ProfileStop();
};
{ {
const cmpTrigger = Engine.QueryInterface(SYSTEM_ENTITY, IID_Trigger); const cmpTrigger = Engine.QueryInterface(SYSTEM_ENTITY, IID_Trigger);
cmpTrigger.RegisterTrigger("OnEntityRenamed", "WonderVictoryEntityRenamed", { "enabled": true }); cmpTrigger.RegisterTrigger("OnEntityRenamed", "WonderVictoryEntityRenamed", { "enabled": true });
cmpTrigger.RegisterTrigger("OnOwnershipChanged", "WonderVictoryOwnershipChanged", { "enabled": true }); cmpTrigger.RegisterTrigger("OnOwnershipChanged", "WonderVictoryOwnershipChanged", { "enabled": true });
cmpTrigger.RegisterTrigger("OnDiplomacyChanged", "WonderVictoryDiplomacyChanged", { "enabled": true }); cmpTrigger.RegisterTrigger("OnDiplomacyChanged", "WonderVictoryDiplomacyChanged", { "enabled": true });
cmpTrigger.RegisterTrigger("OnPlayerWon", "WonderVictoryPlayerWon", { "enabled": true }); cmpTrigger.RegisterTrigger("OnPlayerWon", "WonderVictoryPlayerWon", { "enabled": true });
cmpTrigger.RegisterTrigger("OnConstructionStarted", "WonderStartNotification", { "enabled": true });
cmpTrigger.DoAfterDelay(0, "RevealGaiaWonders", {});
cmpTrigger.wonderVictoryMessages = {}; cmpTrigger.wonderVictoryMessages = {};
} }

View file

@ -29,6 +29,7 @@ Fogging.prototype.Init = function()
this.mirages = []; this.mirages = [];
this.miraged = []; this.miraged = [];
this.seen = []; this.seen = [];
this.revealed = [];
const numPlayers = Engine.QueryInterface(SYSTEM_ENTITY, IID_PlayerManager).GetNumPlayers(); const numPlayers = Engine.QueryInterface(SYSTEM_ENTITY, IID_PlayerManager).GetNumPlayers();
for (let player = 0; player < numPlayers; ++player) for (let player = 0; player < numPlayers; ++player)
@ -36,6 +37,7 @@ Fogging.prototype.Init = function()
this.mirages.push(INVALID_ENTITY); this.mirages.push(INVALID_ENTITY);
this.miraged.push(false); this.miraged.push(false);
this.seen.push(false); this.seen.push(false);
this.revealed.push(false);
} }
}; };
@ -169,6 +171,45 @@ Fogging.prototype.WasSeen = function(player)
return this.seen[player]; return this.seen[player];
}; };
/**
* Permanently reveal this entity to one or all players.
* Destroys existing mirages and optionally deactivates fogging entirely.
* @param {number|string} player - Player ID to reveal to, or "all" for all players.
*/
Fogging.prototype.PermanentlyReveal = function(player = "all")
{
const cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager);
// Validate single player case
if (player !== "all" && (player < 0 || player >= this.mirages.length))
return;
// Determine which players to reveal
const startPlayer = (player === "all") ? 0 : player;
const endPlayer = (player === "all") ? this.mirages.length : player + 1;
// Reveal each player in the range
for (let i = startPlayer; i < endPlayer; ++i)
{
if (this.mirages[i] != INVALID_ENTITY)
{
Engine.DestroyEntity(this.mirages[i]);
this.mirages[i] = INVALID_ENTITY;
this.miraged[i] = false;
}
this.revealed[i] = true;
this.seen[i] = true;
}
// Deactivate fogging if revealing to all players
if (player === "all")
this.activated = false;
// Request visibility update
if (cmpRangeManager)
cmpRangeManager.RequestVisibilityUpdate(this.entity);
};
Fogging.prototype.OnOwnershipChanged = function(msg) Fogging.prototype.OnOwnershipChanged = function(msg)
{ {
// Always activate fogging for non-Gaia entities. // Always activate fogging for non-Gaia entities.
@ -209,7 +250,8 @@ Fogging.prototype.OnVisibilityChanged = function(msg)
this.seen[msg.player] = true; this.seen[msg.player] = true;
} }
if (msg.newVisibility == VIS_FOGGED && this.activated) // Only create a mirage if the player hasnt been permanently revealed
if (msg.newVisibility == VIS_FOGGED && this.activated && !this.revealed[msg.player])
this.LoadMirage(msg.player); this.LoadMirage(msg.player);
}; };

View file

@ -424,10 +424,42 @@ UnitAI.prototype.UnitFsmSpec = {
"Order.Attack": function(msg) "Order.Attack": function(msg)
{ {
const type = this.GetBestAttackAgainst(msg.data.target, msg.data.allowCapture); // Check if this is a group attack
if (!type) const isGroupAttack = !!msg.data.targetArray;
return this.FinishOrder();
// For group attacks with invalid initial target, find one from the array
if (isGroupAttack && (!msg.data.target || msg.data.target === INVALID_ENTITY))
{
if (!this.FindAndSetNextTarget(msg.data))
{
this.FinishOrder();
return ACCEPT_ORDER;
}
}
// Get the attack type for the current target
const type = this.GetBestAttackAgainst(msg.data.target, msg.data.allowCapture);
if (!type)
{
// Current target can't be attacked
if (isGroupAttack)
{
if (this.FindAndSetNextTarget(msg.data))
{
msg.data.attackType = this.GetBestAttackAgainst(msg.data.target, msg.data.allowCapture);
if (msg.data.attackType)
{
this.SetNextState("INDIVIDUAL.COMBAT.ATTACKING");
return ACCEPT_ORDER;
}
}
}
this.FinishOrder();
return ACCEPT_ORDER;
}
// Set attackType before range check
msg.data.attackType = type; msg.data.attackType = type;
this.RememberTargetPosition(); this.RememberTargetPosition();
@ -1366,6 +1398,12 @@ UnitAI.prototype.UnitFsmSpec = {
// Wait for individual members to finish // Wait for individual members to finish
"enter": function(msg) "enter": function(msg)
{ {
if (this.order.data.targetArray)
{
// For group attacks, we're done once we distributed targets
this.FinishOrder();
return true;
}
const target = this.order.data.target; const target = this.order.data.target;
if (!this.CheckFormationTargetAttackRange(target)) if (!this.CheckFormationTargetAttackRange(target))
{ {
@ -1602,6 +1640,26 @@ UnitAI.prototype.UnitFsmSpec = {
"INDIVIDUAL": { "INDIVIDUAL": {
"Attacked": function(msg) "Attacked": function(msg)
{ {
// First, check if we have a group attack order and the attacker is in our target array
if (this.order?.type === "Attack" &&
this.order.data?.targetArray?.includes(msg.data.attacker))
{
// Switch to attack the entity that's attacking us
this.order.data.target = msg.data.attacker;
this.order.data.attackType = this.GetBestAttackAgainst(msg.data.attacker, this.order.data.allowCapture);
// Update the current target index
const currentIndex = this.order.data.targetArray.indexOf(msg.data.attacker);
if (currentIndex !== -1)
this.order.data.currentTargetIndex = currentIndex;
// Make sure we're in the right state to handle this
const currentState = this.GetCurrentState();
if (currentState === "INDIVIDUAL.IDLE" || currentState === "INDIVIDUAL.WALKING")
this.SetNextState("INDIVIDUAL.COMBAT.APPROACHING");
return;
}
if (this.GetStance().targetAttackersAlways || !this.order || !this.order.data || !this.order.data.force) if (this.GetStance().targetAttackersAlways || !this.order || !this.order.data || !this.order.data.force)
this.RespondToTargetedEntities([msg.data.attacker]); this.RespondToTargetedEntities([msg.data.attacker]);
}, },
@ -2150,6 +2208,35 @@ UnitAI.prototype.UnitFsmSpec = {
"Attacked": function(msg) "Attacked": function(msg)
{ {
// Check if we're doing a group attack and the attacker is in our target array
if (!this.order.data.targetArray ||
!this.order.data.targetArray.includes(msg.data.attacker) ||
msg.data.attacker === this.order.data.target)
return;
// Switch to attack the entity that's attacking us
this.order.data.target = msg.data.attacker;
this.order.data.attackType = this.GetBestAttackAgainst(msg.data.attacker, this.order.data.allowCapture);
// Update the current target index
const currentIndex = this.order.data.targetArray.indexOf(msg.data.attacker);
if (currentIndex !== -1)
this.order.data.currentTargetIndex = currentIndex;
// Re-evaluate our approach to the new target
if (this.CheckTargetAttackRange(this.order.data.target, this.order.data.attackType))
{
if (this.CanUnpack())
{
this.PushOrderFront("Unpack", { "force": true });
return;
}
this.SetNextState("ATTACKING");
}
// Continue approaching the new target
else if (!this.MoveToTargetAttackRange(this.order.data.target, this.order.data.attackType))
this.FinishOrder();
// If we're already in combat mode, ignore anyone else who's attacking us // If we're already in combat mode, ignore anyone else who's attacking us
// unless it's a melee attack since they may be blocking our way to the target // unless it's a melee attack since they may be blocking our way to the target
if (msg.data.type == "Melee" && (this.GetStance().targetAttackersAlways || !this.order.data.force)) if (msg.data.type == "Melee" && (this.GetStance().targetAttackersAlways || !this.order.data.force))
@ -2209,7 +2296,23 @@ UnitAI.prototype.UnitFsmSpec = {
"MovementUpdate": function(msg) "MovementUpdate": function(msg)
{ {
if (msg.likelyFailure) if (!msg.likelyFailure)
{
if (!this.CheckTargetAttackRange(this.order.data.target, this.order.data.attackType))
return;
if (this.CanUnpack())
{
this.PushOrderFront("Unpack", { "force": true });
return;
}
this.SetNextState("ATTACKING");
return;
}
// For group attacks, try next target instead of moving to last position
if (!this.order.data.targetArray || this.order.data.currentTargetIndex === undefined)
{ {
// This also handles hunting. // This also handles hunting.
if (this.orderQueue.length > 1) if (this.orderQueue.length > 1)
@ -2217,11 +2320,13 @@ UnitAI.prototype.UnitFsmSpec = {
this.FinishOrder(); this.FinishOrder();
return; return;
} }
else if (!this.order.data.force || !this.order.data.lastPos)
if (!this.order.data.force || !this.order.data.lastPos)
{ {
this.SetNextState("COMBAT.FINDINGNEWTARGET"); this.SetNextState("COMBAT.FINDINGNEWTARGET");
return; return;
} }
// If the order was forced, try moving to the target position, // If the order was forced, try moving to the target position,
// under the assumption that this is desirable if the target // under the assumption that this is desirable if the target
// was somewhat far away - we'll likely end up closer to where // was somewhat far away - we'll likely end up closer to where
@ -2234,21 +2339,30 @@ UnitAI.prototype.UnitFsmSpec = {
return; return;
} }
if (this.CheckTargetAttackRange(this.order.data.target, this.order.data.attackType)) // Handle group attack case
// Remove current unreachable target from array
const currentIndex = this.order.data.currentTargetIndex;
if (currentIndex < this.order.data.targetArray.length)
this.order.data.targetArray.splice(currentIndex, 1);
// Try to find next valid target
if (this.order.data.targetArray.length === 0 || !this.FindAndSetNextTarget(this.order.data))
{ {
if (this.CanUnpack()) this.FinishOrder();
{ return;
this.PushOrderFront("Unpack", { "force": true });
return;
}
this.SetNextState("ATTACKING");
} }
else if (msg.likelySuccess)
// Try moving again, this.order.data.attackType = this.GetBestAttackAgainst(this.order.data.target, this.order.data.allowCapture);
// attack range uses a height-related formula and our actual max range might have changed.
if (!this.MoveToTargetAttackRange(this.order.data.target, this.order.data.attackType)) // Try approaching the new target
this.FinishOrder(); if (this.MoveToTargetAttackRange(this.order.data.target, this.order.data.attackType))
}, return;
// If we can't approach the new target either, remove it and continue
this.order.data.targetArray.splice(this.order.data.currentTargetIndex, 1);
if (this.order.data.targetArray.length === 0)
this.FinishOrder();
}
}, },
"ATTACKING": { "ATTACKING": {
@ -2322,6 +2436,18 @@ UnitAI.prototype.UnitFsmSpec = {
"OutOfRange": function() "OutOfRange": function()
{ {
// Check if the target is garrisoned or in a turret (turreted entities cannot be attacked by melee units)
const cmpTargetUnitAI = Engine.QueryInterface(this.order.data.target, IID_UnitAI);
const isTargetGarrisoned = cmpTargetUnitAI && cmpTargetUnitAI.isGarrisoned;
const isTargetInTurret = cmpTargetUnitAI && cmpTargetUnitAI.IsTurret();
// If target is garrisoned or in a turret (and we're not ranged), treat as invalid
if (isTargetGarrisoned || (isTargetInTurret && this.order.data.attackType !== "Ranged"))
{
this.ProcessMessage("TargetInvalidated");
return;
}
if (this.ShouldChaseTargetedEntity(this.order.data.target, this.order.data.force)) if (this.ShouldChaseTargetedEntity(this.order.data.target, this.order.data.force))
{ {
if (this.CanPack()) if (this.CanPack())
@ -2332,11 +2458,53 @@ UnitAI.prototype.UnitFsmSpec = {
this.SetNextState("CHASING"); this.SetNextState("CHASING");
return; return;
} }
// For group attacks, try next target instead of finding new ones
if (this.order.data.targetArray && this.order.data.currentTargetIndex !== undefined)
{
const currentIndex = this.order.data.currentTargetIndex;
// Remove current target since it's out of range and we can't chase
this.order.data.targetArray.splice(currentIndex, 1);
// Try to find next valid target in sequence
if (this.order.data.targetArray.length > 0 && this.FindAndSetNextTarget(this.order.data))
{
this.order.data.attackType = this.GetBestAttackAgainst(this.order.data.target, this.order.data.allowCapture);
this.SetNextState("INDIVIDUAL.COMBAT.ATTACKING");
return;
}
}
this.SetNextState("FINDINGNEWTARGET"); this.SetNextState("FINDINGNEWTARGET");
}, },
"TargetInvalidated": function() "TargetInvalidated": function()
{ {
// For group attacks, remove current target and move to next in array
if (this.order.data.targetArray && this.order.data.currentTargetIndex !== undefined)
{
// Remove the current target from the array
const currentIndex = this.order.data.currentTargetIndex;
if (currentIndex < this.order.data.targetArray.length)
{
this.order.data.targetArray.splice(currentIndex, 1);
// If we have more targets, continue with the next one
if (this.order.data.targetArray.length > 0)
{
// Try to find next valid target in sequence
if (this.FindAndSetNextTarget(this.order.data))
{
this.order.data.attackType = this.GetBestAttackAgainst(this.order.data.target, this.order.data.allowCapture);
this.SetNextState("INDIVIDUAL.COMBAT.ATTACKING");
return;
}
}
}
}
// No more targets or not a group attack - use original behavior
this.SetNextState("FINDINGNEWTARGET"); this.SetNextState("FINDINGNEWTARGET");
}, },
@ -2360,7 +2528,26 @@ UnitAI.prototype.UnitFsmSpec = {
"enter": function() "enter": function()
{ {
// Check if we are attacking a formation. // First, check if we have pending targets in a group attack
if (this.order.data.targetArray && this.order.data.currentTargetIndex !== undefined)
{
// Try to find next valid target from the array
if (this.FindAndSetNextTarget(this.order.data))
{
this.order.data.attackType = this.GetBestAttackAgainst(this.order.data.target, this.order.data.allowCapture);
this.SetNextState("COMBAT.ATTACKING");
return true;
}
// No valid targets in array - clean up and continue with normal behavior
this.order.data.targetArray = [];
this.order.data.currentTargetIndex = undefined;
}
if (!this.order.data.target)
return false;
// Try to find the formation the target was a part of.
let cmpFormation = Engine.QueryInterface(this.order.data.target, IID_Formation); let cmpFormation = Engine.QueryInterface(this.order.data.target, IID_Formation);
if (cmpFormation) if (cmpFormation)
this.order.data.formationTarget = this.order.data.target; this.order.data.formationTarget = this.order.data.target;
@ -2468,6 +2655,12 @@ UnitAI.prototype.UnitFsmSpec = {
{ {
if (msg.likelyFailure) if (msg.likelyFailure)
{ {
// If this is a group attack with pending targets, try the next one
if (this.order.data.targetArray && this.order.data.targetArray.length > 0)
{
this.SetNextState("COMBAT.FINDINGNEWTARGET");
return;
}
// This also handles hunting. // This also handles hunting.
if (this.orderQueue.length > 1) if (this.orderQueue.length > 1)
{ {
@ -3887,6 +4080,9 @@ UnitAI.prototype.OnOwnershipChanged = function(msg)
UnitAI.prototype.OnDestroy = function() UnitAI.prototype.OnDestroy = function()
{ {
// Clean up all group attack states before destruction
this.CleanupAllGroupAttackStates();
// Switch to an empty state to let states execute their leave handlers. // Switch to an empty state to let states execute their leave handlers.
this.UnitFsm.SwitchToNextState(this, ""); this.UnitFsm.SwitchToNextState(this, "");
@ -4093,6 +4289,9 @@ UnitAI.prototype.FsmStateNameChanged = function(state)
*/ */
UnitAI.prototype.FinishOrder = function() UnitAI.prototype.FinishOrder = function()
{ {
// Clean up any group attack state before removing order
this.CleanupGroupAttackState(this.order);
if (!this.orderQueue.length) if (!this.orderQueue.length)
{ {
const stack = new Error().stack.trimRight().replace(/^/mg, ' '); // indent each line const stack = new Error().stack.trimRight().replace(/^/mg, ' '); // indent each line
@ -4318,15 +4517,24 @@ UnitAI.prototype.ReplaceOrder = function(type, data)
const idx = this.orderQueue.findIndex(o => o.type == "LeaveFormation"); const idx = this.orderQueue.findIndex(o => o.type == "LeaveFormation");
if (idx === -1) if (idx === -1)
{ {
// Clean up all queued orders before clearing them
this.CleanupAllGroupAttackStates();
this.orderQueue = []; this.orderQueue = [];
this.order = undefined; this.order = undefined;
} }
else else
{
// Clean up orders being discarded
for (let i = 0; i < idx; ++i)
this.CleanupGroupAttackState(this.orderQueue[i]);
this.orderQueue.splice(0, idx); this.orderQueue.splice(0, idx);
}
this.PushOrderFront(type, data); this.PushOrderFront(type, data);
} }
else else
{ {
// Clean up all queued orders before clearing them
this.CleanupAllGroupAttackStates();
this.orderQueue = []; this.orderQueue = [];
this.PushOrder(type, data); this.PushOrder(type, data);
} }
@ -4405,6 +4613,7 @@ UnitAI.prototype.BackToWork = function()
if (this.IsTurret(cmpTurretable) && !cmpTurretable.LeaveTurret()) if (this.IsTurret(cmpTurretable) && !cmpTurretable.LeaveTurret())
return false; return false;
this.CleanupAllGroupAttackStates();
this.orderQueue = []; this.orderQueue = [];
this.AddOrders(this.workOrders); this.AddOrders(this.workOrders);
@ -5060,6 +5269,9 @@ UnitAI.prototype.CheckTargetRange = function(target, iid, type)
*/ */
UnitAI.prototype.CheckTargetAttackRange = function(target, type) UnitAI.prototype.CheckTargetAttackRange = function(target, type)
{ {
if (!target || target === INVALID_ENTITY || !type)
return false;
// for formation members, the formation will take care of the range check // for formation members, the formation will take care of the range check
if (this.IsFormationMember()) if (this.IsFormationMember())
{ {
@ -5793,6 +6005,75 @@ UnitAI.prototype.Attack = function(target, allowCapture = this.DEFAULT_CAPTURE,
this.AddOrder("Attack", order, queued, pushFront); this.AddOrder("Attack", order, queued, pushFront);
}; };
/**
* Adds a group attack order to the queue.
* Used when some unit(s) are ordered to attack multiple targets.
* Each unit receives the full target list (from Commands) but starts at a different position
* based on the startingIndex in the attacking group.
*
* @param {Array} targets - Array of target entity IDs to attack (pre-sorted by distance)
* @param {number} startingIndex - This unit's position in the attacking group (0-based)
* @param {number} unitCount - Total number of units in the attacking group
* @param {boolean} allowCapture - Whether capturing is allowed
* @param {boolean} queued - If true, add to end of queue instead of replacing current order
* @param {boolean} pushFront - If true, add to front of queue (interrupt current order)
*/
UnitAI.prototype.AttackGroup = function(targets, startingIndex, unitCount, allowCapture = this.DEFAULT_CAPTURE, queued = false, pushFront = false)
{
if (!targets?.length)
return;
// Check if adding these targets would exceed the limit
const currentTotal = this.CountTotalTargetsInQueue();
const newTargetCount = targets.length;
if (currentTotal + newTargetCount > this.MAX_TOTAL_TARGETS)
{
// Trim the targets to fit within the limit
const allowedNewTargets = Math.max(0, this.MAX_TOTAL_TARGETS - currentTotal);
if (allowedNewTargets === 0)
return;
// Trim the target array
targets = targets.slice(0, allowedNewTargets);
// If trimming results in no targets, just return
if (targets.length === 0)
return;
}
// If this unit is in a formation, move the formation controller out of the world
// This prevents the controller from staying at the coordinates where the units received the order
// That can potentially be far apart from where the fighting will occur.
// Result: The formation will reform into a more logical position once members are done (idle).
this.MoveFormationControllerOutOfWorld();
const stride = targets.length / unitCount;
const startIdx = Math.min(Math.round((startingIndex + 0.5) * stride), targets.length - 1);
const order = {
"force": true,
"allowCapture": allowCapture,
"targetArray": targets.slice(),
"target": INVALID_ENTITY,
"currentTargetIndex": startIdx,
"originalFirstTarget": targets[0],
};
this.RememberTargetPosition(order);
this.AddOrder("Attack", order, queued, pushFront);
};
UnitAI.prototype.MoveFormationControllerOutOfWorld = function()
{
if (!this.IsFormationMember() || !this.formationController)
return;
const cmpControllerPosition = Engine.QueryInterface(this.formationController, IID_Position);
if (cmpControllerPosition?.IsInWorld())
cmpControllerPosition.MoveOutOfWorld();
};
/** /**
* Adds garrison order to the queue, forced by the player. * Adds garrison order to the queue, forced by the player.
*/ */
@ -6726,6 +7007,72 @@ UnitAI.prototype.AttackEntitiesByPreference = function(ents)
return this.RespondToTargetedEntities(entsWithoutPref); return this.RespondToTargetedEntities(entsWithoutPref);
}; };
UnitAI.prototype.FindAndSetNextTarget = function(data)
{
if (!this.EnsureValidTargetIndex(data))
return false;
const startIdx = data.currentTargetIndex;
const currentTarget = data.targetArray[startIdx];
// First check the starting target
if (this.CanAttack(currentTarget) && this.CheckTargetVisible(currentTarget))
return this.SetNextTarget(data, startIdx);
// Search forward using find
const forwardFound = data.targetArray
.slice(startIdx + 1)
.findIndex((target, i) =>
this.CanAttack(target) && this.CheckTargetVisible(target)
);
if (forwardFound !== -1)
return this.SetNextTarget(data, startIdx + 1 + forwardFound);
// Search backward using find
const backwardFound = data.targetArray
.slice(0, startIdx)
.reverse()
.findIndex((target, i) =>
this.CanAttack(target) && this.CheckTargetVisible(target)
);
if (backwardFound !== -1)
{
// Convert reverse index back to original index
const originalIndex = startIdx - 1 - backwardFound;
return this.SetNextTarget(data, originalIndex);
}
return false;
};
UnitAI.prototype.SetNextTarget = function(data, index)
{
const target = data.targetArray[index];
data.target = target;
data.attackType = this.GetBestAttackAgainst(target, data.allowCapture);
data.currentTargetIndex = index;
return true;
};
UnitAI.prototype.EnsureValidTargetIndex = function(data)
{
if (!data.targetArray || data.currentTargetIndex === undefined)
return false;
// If array is empty, clear the index
if (data.targetArray.length === 0)
{
data.currentTargetIndex = undefined;
return false;
}
data.currentTargetIndex = Math.clamp(data.currentTargetIndex, 0, data.targetArray.length - 1);
return true;
};
/** /**
* Call UnitAI.funcname(args) on all formation members. * Call UnitAI.funcname(args) on all formation members.
* @param resetFinishedEntities - If true, call ResetFinishedEntities first. * @param resetFinishedEntities - If true, call ResetFinishedEntities first.
@ -6837,6 +7184,50 @@ UnitAI.prototype.ResetObstructionMitigationFlag = function()
delete this.obstructionMitigationAttempted; delete this.obstructionMitigationAttempted;
}; };
/**
* Counts the total number of target entities across all queued orders.
* This includes targetArray from group attacks and single targets.
* Each target reference is counted separately, even if the same entity
* appears in multiple orders or in both targetArray and target.
* @returns {number} - Total number of target entity references in the order queue
*/
UnitAI.prototype.CountTotalTargetsInQueue = function()
{
let total = 0;
for (const order of this.orderQueue)
{
if (order.data)
{
// Count group attack targets
if (order.data.targetArray && Array.isArray(order.data.targetArray))
total += order.data.targetArray.length;
// Add single target for consistency
if (order.data.target && order.data.target !== INVALID_ENTITY)
total++;
}
}
return total;
};
UnitAI.prototype.CleanupGroupAttackState = function(order)
{
if (order?.data?.targetArray)
{
delete order.data.targetArray;
delete order.data.currentTargetIndex;
}
};
UnitAI.prototype.CleanupAllGroupAttackStates = function()
{
for (const order of this.orderQueue)
this.CleanupGroupAttackState(order);
};
// Maximum number of target entities allowed across all queued orders
UnitAI.prototype.MAX_TOTAL_TARGETS = 1000;
UnitAI.prototype.UnitFsm = new FSM(UnitAI.prototype.UnitFsmSpec); UnitAI.prototype.UnitFsm = new FSM(UnitAI.prototype.UnitFsmSpec);
Engine.RegisterComponentType(IID_UnitAI, "UnitAI", UnitAI); Engine.RegisterComponentType(IID_UnitAI, "UnitAI", UnitAI);

View file

@ -138,6 +138,7 @@ function TestFormationExiting(mode)
"ResetActiveQuery": function(id) { if (mode == 0) return []; return [enemy]; }, "ResetActiveQuery": function(id) { if (mode == 0) return []; return [enemy]; },
"DisableActiveQuery": function(id) { }, "DisableActiveQuery": function(id) { },
"GetEntityFlagMask": function(identifier) { }, "GetEntityFlagMask": function(identifier) { },
"GetLosVisibility": (ent, player) => "visible"
}); });
AddMock(SYSTEM_ENTITY, IID_TemplateManager, { AddMock(SYSTEM_ENTITY, IID_TemplateManager, {
@ -319,8 +320,11 @@ function TestMoveIntoFormationWhileAttacking()
"ResetActiveQuery": function(id) { return [enemy]; }, "ResetActiveQuery": function(id) { return [enemy]; },
"DisableActiveQuery": function(id) { }, "DisableActiveQuery": function(id) { },
"GetEntityFlagMask": function(identifier) { }, "GetEntityFlagMask": function(identifier) { },
"GetLosVisibility": (target, player) => "visible",
"GetLosVisibilityPosition": (x, z, player) => "visible"
}); });
AddMock(SYSTEM_ENTITY, IID_TemplateManager, { AddMock(SYSTEM_ENTITY, IID_TemplateManager, {
"GetCurrentTemplateName": function(ent) { return "special/formations/line_closed"; }, "GetCurrentTemplateName": function(ent) { return "special/formations/line_closed"; },
}); });
@ -406,6 +410,10 @@ function TestMoveIntoFormationWhileAttacking()
"GetHitpoints": function() { return 40; }, "GetHitpoints": function() { return 40; },
}); });
AddMock(enemy, IID_Ownership, {
"GetOwner": () => 2 // Different player ID (enemy)
});
const controllerFormation = ConstructComponent(controller, "Formation", { const controllerFormation = ConstructComponent(controller, "Formation", {
"FormationShape": "square", "FormationShape": "square",
"ShiftRows": "false", "ShiftRows": "false",
@ -421,6 +429,10 @@ function TestMoveIntoFormationWhileAttacking()
"DefaultStance": "aggressive" "DefaultStance": "aggressive"
}); });
AddMock(controller, IID_Ownership, {
"GetOwner": () => 1
});
AddMock(controller, IID_Position, { AddMock(controller, IID_Position, {
"GetTurretParent": () => INVALID_ENTITY, "GetTurretParent": () => INVALID_ENTITY,
"JumpTo": function(x, z) { this.x = x; this.z = z; }, "JumpTo": function(x, z) { this.x = x; this.z = z; },
@ -458,7 +470,7 @@ function TestMoveIntoFormationWhileAttacking()
controllerFormation.SetMembers(units); controllerFormation.SetMembers(units);
controllerAI.Attack(enemy, []); controllerAI.Attack(enemy);
for (const ent of unitAIs) for (const ent of unitAIs)
TS_ASSERT_EQUALS(unitAI.fsmStateName, "INDIVIDUAL.COMBAT.ATTACKING"); TS_ASSERT_EQUALS(unitAI.fsmStateName, "INDIVIDUAL.COMBAT.ATTACKING");
@ -889,4 +901,362 @@ function TestAttemptObstructionMitigation()
})(); })();
} }
TestAttemptObstructionMitigation(); TestAttemptObstructionMitigation();
function TestFindAndSetNextTargetBehavior()
{
ResetState();
const attacker = 10;
const targets = [20, 21, 22, 23, 24]; // Multiple targets for comprehensive testing
// Basic mocks needed for unitAI creation
AddMock(SYSTEM_ENTITY, IID_Timer, {
"SetInterval": () => {},
"SetTimeout": () => {}
});
AddMock(SYSTEM_ENTITY, IID_RangeManager, {
"CreateActiveQuery": () => 1,
"EnableActiveQuery": () => {},
"ResetActiveQuery": () => [],
"DisableActiveQuery": () => {},
"GetEntityFlagMask": () => 0
});
AddMock(SYSTEM_ENTITY, IID_ObstructionManager, {
"IsInTargetRange": () => false
});
// Create the unitAI component
const unitAI = ConstructComponent(attacker, "UnitAI", {
"FormationController": "false",
"DefaultStance": "aggressive"
});
AddMock(attacker, IID_Attack, {
"GetBestAttackAgainst": () => "Melee"
});
unitAI.OnCreate();
// Mock the visibility and attackability checks
unitAI.CanAttack = (target) =>
{
// Target 21 is always unattackable (e.g., garrisoned, dead, etc.)
return target !== 21;
};
unitAI.CheckTargetVisible = (target) =>
{
// Target 22 is always not visible
return target !== 22;
};
// First target is valid
(function()
{
const data = {
"targetArray": [20, 21, 22, 23, 24],
"currentTargetIndex": 0, // Currently targeting 20
"target": 20,
"allowCapture": false
};
const found = unitAI.FindAndSetNextTarget(data);
TS_ASSERT(found);
TS_ASSERT_EQUALS(data.target, 20);
TS_ASSERT_EQUALS(data.currentTargetIndex, 0);
})();
// End of array behavior
(function()
{
const data = {
"targetArray": [18, 19, 20, 21, 22],
"currentTargetIndex": 2, // Currently targeting 22
"allowCapture": false
};
// 21 and 22 are invalid, should go backward and find 20
const found = unitAI.FindAndSetNextTarget(data);
TS_ASSERT(found);
TS_ASSERT_EQUALS(data.target, 20); // Should be assigned targetArray[initialTarget - 1]
})();
// No valid targets found
(function()
{
const data = {
"targetArray": [21, 22], // Both are invalid (21 unattackable, 22 not visible)
"currentTargetIndex": 0,
"target": 21,
"allowCapture": false
};
const found = unitAI.FindAndSetNextTarget(data);
TS_ASSERT(!found);
TS_ASSERT_EQUALS(data.target, 21); // Should remain unchanged
TS_ASSERT_EQUALS(data.currentTargetIndex, 0);
})();
// Empty target array
(function()
{
const data = {
"targetArray": [],
"currentTargetIndex": 0,
"target": 20,
"allowCapture": false
};
const found = unitAI.FindAndSetNextTarget(data);
TS_ASSERT(!found);
TS_ASSERT_EQUALS(data.target, 20); // Should remain unchanged
TS_ASSERT_EQUALS(data.currentTargetIndex, void 0); // Should nullify currentTargetIndex
})();
// Current target is last in array
(function()
{
const data = {
"targetArray": [20, 21, 22, 23, 24],
"currentTargetIndex": 4, // Currently targeting 24 (last element)
"allowCapture": false
};
const found = unitAI.FindAndSetNextTarget(data);
TS_ASSERT(found);
TS_ASSERT_EQUALS(data.target, 24); // Should remain unchanged
TS_ASSERT_EQUALS(data.currentTargetIndex, 4);
})();
// Single invalid target
(function()
{
const data = {
"targetArray": [21], // Only one target, which is invalid
"currentTargetIndex": 0,
"allowCapture": false
};
const found = unitAI.FindAndSetNextTarget(data);
TS_ASSERT(!found);
})();
// Verify if we don't do more calls then necessary
(function()
{
const searchOrder = [];
unitAI.CanAttack = (target) =>
{
searchOrder.push(target);
return target == 20;
};
unitAI.CheckTargetVisible = (target) => true; // All visible
const data = {
"targetArray": [20, 21, 22, 23],
"currentTargetIndex": 1, // Currently targeting 21
"allowCapture": false
};
const found = unitAI.FindAndSetNextTarget(data);
TS_ASSERT(found);
TS_ASSERT_EQUALS(searchOrder.length, 4); // Should find at 20 (4 calls)
})();
}
TestFindAndSetNextTargetBehavior();
function TestAttackGroupBehavior()
{
ResetState();
const attacker1 = 100;
const attacker2 = 101;
const targets = [200, 201, 202, 203, 204];
// Basic mocks needed for unitAI creation
AddMock(SYSTEM_ENTITY, IID_Timer, {
"SetInterval": () => {},
"SetTimeout": () => {}
});
AddMock(SYSTEM_ENTITY, IID_RangeManager, {
"CreateActiveQuery": () => 1,
"EnableActiveQuery": () => {},
"ResetActiveQuery": () => [],
"DisableActiveQuery": () => {},
"GetEntityFlagMask": () => 0
});
AddMock(SYSTEM_ENTITY, IID_ObstructionManager, {
"IsInTargetRange": () => false
});
// Create two unitAI components
const unitAI1 = ConstructComponent(attacker1, "UnitAI", {
"FormationController": "false",
"DefaultStance": "aggressive"
});
const unitAI2 = ConstructComponent(attacker2, "UnitAI", {
"FormationController": "false",
"DefaultStance": "aggressive"
});
AddMock(attacker1, IID_Attack, {
"GetBestAttackAgainst": () => "Melee"
});
AddMock(attacker2, IID_Attack, {
"GetBestAttackAgainst": () => "Melee"
});
unitAI1.OnCreate();
unitAI2.OnCreate();
// Basic AttackGroup distribution
(function()
{
let orderAdded1 = null;
let orderAdded2 = null;
// Mock AddOrder to capture what orders are created
unitAI1.AddOrder = (type, data, queued, pushFront) =>
{
orderAdded1 = { type, data, queued, pushFront };
};
unitAI2.AddOrder = (type, data, queued, pushFront) =>
{
orderAdded2 = { type, data, queued, pushFront };
};
// Call AttackGroup for both units
unitAI1.AttackGroup(targets, 0, 2, false, false, false);
unitAI2.AttackGroup(targets, 1, 2, false, false, false);
// Verify unit1's order
TS_ASSERT(orderAdded1);
TS_ASSERT_EQUALS(orderAdded1.type, "Attack");
TS_ASSERT_EQUALS(orderAdded1.data.targetArray.length, 5);
TS_ASSERT_EQUALS(orderAdded1.data.target, INVALID_ENTITY);
// Unit 0 should start at position 1 ==> Math.round((0 + 0.5) * 2.5) = Math.round(1.25) = 1
TS_ASSERT_EQUALS(orderAdded1.data.currentTargetIndex, 1);
// Verify unit2's order
TS_ASSERT(orderAdded2);
TS_ASSERT_EQUALS(orderAdded2.type, "Attack");
TS_ASSERT_EQUALS(orderAdded2.data.targetArray.length, 5);
TS_ASSERT_EQUALS(orderAdded2.data.target, INVALID_ENTITY);
// Unit 1 should start at position 4 ==> Math.round((1 + 0.5) * 2.5) = Math.round(3.75) = 4
TS_ASSERT_EQUALS(orderAdded2.data.currentTargetIndex, 4);
})();
// More units than targets
(function()
{
let orderAdded1 = null;
let orderAdded2 = null;
let orderAdded3 = null;
const unitAI3 = ConstructComponent(102, "UnitAI", {
"FormationController": "false",
"DefaultStance": "aggressive"
});
AddMock(102, IID_Attack, { "GetBestAttackAgainst": () => "Melee" });
unitAI3.OnCreate();
unitAI1.AddOrder = (type, data, queued, pushFront) =>
{
orderAdded1 = { type, data, queued, pushFront };
};
unitAI2.AddOrder = (type, data, queued, pushFront) =>
{
orderAdded2 = { type, data, queued, pushFront };
};
unitAI3.AddOrder = (type, data, queued, pushFront) =>
{
orderAdded3 = { type, data, queued, pushFront };
};
const smallTargets = [300, 301];
unitAI1.AttackGroup(smallTargets, 0, 3, true, true, false);
unitAI2.AttackGroup(smallTargets, 1, 3, true, true, false);
unitAI3.AttackGroup(smallTargets, 2, 3, true, true, false);
// Verify distribution when more units than targets
TS_ASSERT(orderAdded1);
TS_ASSERT_EQUALS(orderAdded1.data.currentTargetIndex, 0); // 0 * 2 / 3 = 0
TS_ASSERT_EQUALS(orderAdded1.data.allowCapture, true);
TS_ASSERT_EQUALS(orderAdded1.queued, true);
TS_ASSERT(orderAdded2);
TS_ASSERT_EQUALS(orderAdded2.data.currentTargetIndex, 1); // 1 * 2 / 3 = 0.66, round = 1
TS_ASSERT(orderAdded2.data.currentTargetIndex < smallTargets.length);
TS_ASSERT(orderAdded3);
TS_ASSERT_EQUALS(orderAdded3.data.currentTargetIndex, 1); // 2 * 2 / 3 = 1.33, round = 1
TS_ASSERT(orderAdded3.data.currentTargetIndex < smallTargets.length);
})();
// Edge case - single target
(function()
{
let orderAdded = null;
unitAI1.AddOrder = (type, data, queued, pushFront) =>
{
orderAdded = { type, data, queued, pushFront };
};
const singleTarget = [400];
unitAI1.AttackGroup(singleTarget, 0, 1, false, false, true);
TS_ASSERT(orderAdded);
TS_ASSERT_EQUALS(orderAdded.data.targetArray.length, 1);
TS_ASSERT_EQUALS(orderAdded.data.currentTargetIndex, 0);
TS_ASSERT_EQUALS(orderAdded.pushFront, true);
})();
// Empty targets array should do nothing
(function()
{
let callCount = 0;
unitAI1.AddOrder = () => { callCount++; };
unitAI1.AttackGroup([], 0, 1);
TS_ASSERT_EQUALS(callCount, 0);
unitAI1.AttackGroup(null, 0, 1);
TS_ASSERT_EQUALS(callCount, 0);
unitAI1.AttackGroup(undefined, 0, 1);
TS_ASSERT_EQUALS(callCount, 0);
})();
// Verify RememberTargetPosition is called
(function()
{
let rememberedOrder = null;
unitAI1.RememberTargetPosition = (order) =>
{
rememberedOrder = order;
};
unitAI1.AddOrder = () => {};
const testTargets = [500, 501];
unitAI1.AttackGroup(testTargets, 0, 1);
TS_ASSERT(rememberedOrder);
TS_ASSERT_EQUALS(rememberedOrder.targetArray.length, 2);
})();
}
TestAttackGroupBehavior();

View file

@ -8,5 +8,5 @@
{ "value": "Researcher/TechCostMultiplier/metal", "multiply": 0.9 } { "value": "Researcher/TechCostMultiplier/metal", "multiply": 0.9 }
], ],
"auraName": "Economic Fortune", "auraName": "Economic Fortune",
"auraDescription": "Solon brought in a new system of weights and measures, fathers were encouraged to find trades for their sons.\nEconomic technologies 10% resource costs." "auraDescription": "Solon brought in a new system of weights and measures, and fathers were encouraged to find trades for their sons.\nEconomic technologies 10% resource costs."
} }

View file

@ -5,5 +5,5 @@
{ "value": "TerritoryInfluence/Radius", "multiply": 1.2 } { "value": "TerritoryInfluence/Radius", "multiply": 1.2 }
], ],
"auraName": "Territorial Expansion", "auraName": "Territorial Expansion",
"auraDescription": "At its height, the Empire's borders spanned from the Fergana Valley in the west, to Korea in the east, and to northern Vietnam in the south.\nTerritory influence bonus +20%." "auraDescription": "At its height, the Empire's borders spanned from the Fergana Valley in the west to Korea in the east and to northern Vietnam in the south.\nTerritory influence bonus +20%."
} }

View file

@ -7,5 +7,5 @@
{ "value": "Resistance/Entity/Damage/Crush", "add": 1 } { "value": "Resistance/Entity/Damage/Crush", "add": 1 }
], ],
"auraName": "Founder and Defender of the Republic", "auraName": "Founder and Defender of the Republic",
"auraDescription": "Brutus was one of the key figures in the overthrow of the monarchy and the founding of the Roman Republic. Later, as consul he led a Roman army to victory against the Etruscan King Tarquinius who sought to retake the throne.\nHumans and Siege Engines +1 crush, hack, pierce resistance." "auraDescription": "Brutus was one of the key figures in the overthrow of the monarchy and the founding of the Roman Republic. Later, as consul, he led a Roman army to victory against the Etruscan King Tarquinius who sought to retake the throne.\nHumans and Siege Engines +1 crush, hack, and pierce resistance."
} }

View file

@ -179,6 +179,28 @@ var g_Commands = {
}); });
}, },
"attack-group": function(player, cmd, data)
{
const unitAIs = data.entities.flatMap(ent =>
{
const cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI);
const cmpAttack = Engine.QueryInterface(ent, IID_Attack);
// Check if unit can move and has attack capability
const canAttack = cmpAttack && cmpAttack.GetAttackTypes().length > 0;
return (cmpUnitAI && cmpUnitAI.AbleToMove() && canAttack) ? [cmpUnitAI] : [];
});
if (!unitAIs.length || !cmd.targets?.length)
return;
// Pass the entire target array and unit count to each UnitAI
unitAIs.forEach((cmpUnitAI, index) =>
{
cmpUnitAI.AttackGroup(cmd.targets, index, unitAIs.length, cmd.allowCapture, cmd.queued, cmd.pushFront);
});
},
"patrol": function(player, cmd, data) "patrol": function(player, cmd, data)
{ {
const ents = data.entities.length; const ents = data.entities.length;

View file

@ -1,4 +1,4 @@
/* Copyright (C) 2025 Wildfire Games. /* Copyright (C) 2026 Wildfire Games.
* This file is part of 0 A.D. * This file is part of 0 A.D.
* *
* 0 A.D. is free software: you can redistribute it and/or modify * 0 A.D. is free software: you can redistribute it and/or modify
@ -54,7 +54,7 @@ pipeline {
stage('Check for shader changes') { stage('Check for shader changes') {
when { when {
anyOf { anyOf {
changeset 'binaries/data/mods/**/shaders/**/*.xml' changeset 'binaries/data/mods/**/shaders/**/*'
changeset 'source/tools/spirv/compile.py' changeset 'source/tools/spirv/compile.py'
} }
} }

View file

@ -572,6 +572,16 @@ extern_lib_defs = {
end end
end, end,
}, },
ngtcp2 = {
compile_settings = function()
pkgconfig.add_includes("libngtcp2")
pkgconfig.add_includes("libngtcp2_crypto_gnutls")
end,
link_settings = function()
pkgconfig.add_links("libngtcp2")
pkgconfig.add_links("libngtcp2_crypto_gnutls")
end
},
nvtt = { nvtt = {
compile_settings = function() compile_settings = function()
if not _OPTIONS["with-system-nvtt"] then if not _OPTIONS["with-system-nvtt"] then

View file

@ -731,6 +731,7 @@ function setup_all_libs ()
"cpp_httplib", "cpp_httplib",
"spidermonkey", "spidermonkey",
"enet", "enet",
"ngtcp2",
"sdl", "sdl",
"boost", -- dragged in via server->simulation.h->random and NetSession.h->lockfree "boost", -- dragged in via server->simulation.h->random and NetSession.h->lockfree
"fmt", "fmt",
@ -802,6 +803,7 @@ function setup_all_libs ()
"spidermonkey", "spidermonkey",
"boost", "boost",
"enet", "enet",
"ngtcp2",
"gloox", "gloox",
"icu", "icu",
"iconv", "iconv",
@ -883,6 +885,7 @@ function setup_all_libs ()
"zlib", "zlib",
"boost", "boost",
"enet", "enet",
"ngtcp2",
"libcurl", "libcurl",
"tinygettext", "tinygettext",
"icu", "icu",
@ -970,6 +973,7 @@ function setup_all_libs ()
"sdl", -- key definitions "sdl", -- key definitions
"boost", "boost",
"enet", "enet",
"ngtcp2",
"tinygettext", "tinygettext",
"icu", "icu",
"iconv", "iconv",
@ -1105,6 +1109,7 @@ used_extern_libs = {
"cxxtest", "cxxtest",
"comsuppw", "comsuppw",
"enet", "enet",
"ngtcp2",
"libcurl", "libcurl",
"tinygettext", "tinygettext",
"icu", "icu",

View file

@ -28,7 +28,7 @@ SDL2_VERSION="SDL2-2.24.0"
# NOTE: remember to also update LIB_URL below when changing version # NOTE: remember to also update LIB_URL below when changing version
BOOST_VERSION="boost_1_81_0" BOOST_VERSION="boost_1_81_0"
# NOTE: remember to also update LIB_URL below when changing version # NOTE: remember to also update LIB_URL below when changing version
WXWIDGETS_VERSION="wxWidgets-3.2.8" WXWIDGETS_VERSION="wxWidgets-3.2.10"
# libpng was included as part of X11 but that's removed from Mountain Lion # libpng was included as part of X11 but that's removed from Mountain Lion
# (also the Snow Leopard version was ancient 1.2) # (also the Snow Leopard version was ancient 1.2)
PNG_VERSION="libpng-1.6.44" PNG_VERSION="libpng-1.6.44"
@ -477,7 +477,7 @@ echo "Building wxWidgets..."
LIB_VERSION="${WXWIDGETS_VERSION}" LIB_VERSION="${WXWIDGETS_VERSION}"
LIB_ARCHIVE="$LIB_VERSION.tar.bz2" LIB_ARCHIVE="$LIB_VERSION.tar.bz2"
LIB_DIRECTORY="$LIB_VERSION" LIB_DIRECTORY="$LIB_VERSION"
LIB_URL="http://github.com/wxWidgets/wxWidgets/releases/download/v3.2.8/" LIB_URL="http://github.com/wxWidgets/wxWidgets/releases/download/v3.2.10/"
mkdir -p wxwidgets mkdir -p wxwidgets
cd wxwidgets cd wxwidgets

View file

@ -33,6 +33,8 @@ echo "Building cpp-httplib..."
if [ -e .already-built ] && [ "$(cat .already-built || true)" = "${LIB_VERSION}" ]; then if [ -e .already-built ] && [ "$(cat .already-built || true)" = "${LIB_VERSION}" ]; then
echo "Skipping - already built (use --force-rebuild to override)" echo "Skipping - already built (use --force-rebuild to override)"
exit exit
else
rm -f .already-built
fi fi
# fetch # fetch

View file

@ -33,6 +33,8 @@ done
if [ -e .already-built ] && [ "$(cat .already-built || true)" = "${LIB_VERSION}" ]; then if [ -e .already-built ] && [ "$(cat .already-built || true)" = "${LIB_VERSION}" ]; then
echo "Skipping - already built (use --force-rebuild to override)" echo "Skipping - already built (use --force-rebuild to override)"
exit exit
else
rm -f .already-built
fi fi
# fetch # fetch

View file

@ -49,6 +49,8 @@ done
if [ -e .already-built ] && [ "$(cat .already-built || true)" = "${LIB_VERSION}" ]; then if [ -e .already-built ] && [ "$(cat .already-built || true)" = "${LIB_VERSION}" ]; then
echo "Skipping - already built (use --force-rebuild to override)" echo "Skipping - already built (use --force-rebuild to override)"
exit exit
else
rm -f .already-built
fi fi
# fetch # fetch

View file

@ -49,6 +49,8 @@ done
if [ -e .already-built ] && [ "$(cat .already-built || true)" = "${LIB_VERSION}" ]; then if [ -e .already-built ] && [ "$(cat .already-built || true)" = "${LIB_VERSION}" ]; then
echo "Skipping - already built (use --force-rebuild to override)" echo "Skipping - already built (use --force-rebuild to override)"
exit exit
else
rm -f .already-built
fi fi
# fetch # fetch

View file

@ -36,6 +36,8 @@ done
if [ -e .already-built ] && [ "$(cat .already-built || true)" = "${LIB_VERSION}" ]; then if [ -e .already-built ] && [ "$(cat .already-built || true)" = "${LIB_VERSION}" ]; then
echo "Skipping - already built (use --force-rebuild to override)" echo "Skipping - already built (use --force-rebuild to override)"
exit exit
else
rm -f .already-built
fi fi
# fetch # fetch

View file

@ -56,6 +56,8 @@ echo "Building SpiderMonkey..."
if [ -e .already-built ] && [ "$(cat .already-built || true)" = "${LIB_VERSION}" ]; then if [ -e .already-built ] && [ "$(cat .already-built || true)" = "${LIB_VERSION}" ]; then
echo "Skipping - already built (use --force-rebuild to override)" echo "Skipping - already built (use --force-rebuild to override)"
exit exit
else
rm -f .already-built
fi fi
OS="${OS:=$(uname -s)}" OS="${OS:=$(uname -s)}"

View file

@ -33,6 +33,8 @@ done
if [ -e .already-built ] && [ "$(cat .already-built || true)" = "${LIB_VERSION}" ]; then if [ -e .already-built ] && [ "$(cat .already-built || true)" = "${LIB_VERSION}" ]; then
echo "Skipping - already built (use --force-rebuild to override)" echo "Skipping - already built (use --force-rebuild to override)"
exit exit
else
rm -f .already-built
fi fi
# fetch # fetch

View file

@ -33,6 +33,7 @@ ignore = [
"ANN", "ANN",
"C90", "C90",
"COM812", "COM812",
"CPY001",
"D10", "D10",
"EM", "EM",
"FIX002", "FIX002",

View file

@ -92,12 +92,12 @@ void CCinemaManager::DrawSpline(Renderer::Backend::IDeviceCommandContext& device
if (spline.GetAllNodes().size() == 2) if (spline.GetAllNodes().size() == 2)
smoothness = 2; smoothness = 2;
const float start = spline.MaxDistance.ToFloat() / smoothness; const float start = spline.GetMaxDistance().ToFloat() / smoothness;
std::vector<CVector3D> line; std::vector<CVector3D> line;
for (int i = 0; i <= smoothness; ++i) for (int i = 0; i <= smoothness; ++i)
{ {
const float time = start * i / spline.MaxDistance.ToFloat(); const float time = start * i / spline.GetMaxDistance().ToFloat();
line.emplace_back(spline.GetPosition(time)); line.emplace_back(spline.GetPosition(time));
} }
@ -108,7 +108,7 @@ void CCinemaManager::DrawSpline(Renderer::Backend::IDeviceCommandContext& device
{ {
for (int i = 0; i <= smoothness; ++i) for (int i = 0; i <= smoothness; ++i)
{ {
const float time = start * i / spline.MaxDistance.ToFloat(); const float time = start * i / spline.GetMaxDistance().ToFloat();
const CVector3D tmp = spline.GetPosition(time); const CVector3D tmp = spline.GetPosition(time);
const float groundY = g_Game->GetWorld()->GetTerrain().GetExactGroundLevel(tmp.X, tmp.Z); const float groundY = g_Game->GetWorld()->GetTerrain().GetExactGroundLevel(tmp.X, tmp.Z);
g_Renderer.GetDebugRenderer().DrawLine(deviceCommandContext, tmp, CVector3D(tmp.X, groundY, tmp.Z), splineColor, 0.1f, false); g_Renderer.GetDebugRenderer().DrawLine(deviceCommandContext, tmp, CVector3D(tmp.X, groundY, tmp.Z), splineColor, 0.1f, false);

View file

@ -53,25 +53,6 @@ public:
void SetElevation(float f); void SetElevation(float f);
void SetRotation(float f); void SetRotation(float f);
/**
* Calculate brightness of a point of a unit with the given normal vector,
* for rendering with CPU lighting.
* The resulting color contains both ambient and diffuse light.
* To cope with sun overbrightness, the color is scaled by 0.5.
*
* @param normal normal vector (must have length 1)
*/
RGBColor EvaluateUnitScaled(const CVector3D& normal) const
{
float dot = -normal.Dot(m_SunDir);
RGBColor color = m_AmbientColor;
if (dot > 0)
color += m_SunColor * dot;
return color * 0.5f;
}
// Comparison operators // Comparison operators
bool operator==(const CLightEnv& o) const bool operator==(const CLightEnv& o) const
{ {

View file

@ -62,8 +62,7 @@ void CMaterial::AddRenderQuery(const char* key)
m_RenderQueries.Add(key); m_RenderQueries.Add(key);
} }
void CMaterial::AddRequiredSampler(const CStr& samplerName) void CMaterial::AddRequiredSampler(RequiredSampler sampler)
{ {
CStrIntern string(samplerName); m_RequiredSamplers.emplace_back(std::move(sampler));
m_RequiredSamplers.push_back(string);
} }

View file

@ -53,6 +53,12 @@ public:
CTexturePtr Sampler; CTexturePtr Sampler;
}; };
struct RequiredSampler
{
CStrIntern name;
bool sRGB;
};
typedef std::vector<TextureSampler> SamplersVector; typedef std::vector<TextureSampler> SamplersVector;
CMaterial(); CMaterial();
@ -82,8 +88,8 @@ public:
void AddRenderQuery(const char* key); void AddRenderQuery(const char* key);
const CShaderRenderQueries& GetRenderQueries() const { return m_RenderQueries; } const CShaderRenderQueries& GetRenderQueries() const { return m_RenderQueries; }
void AddRequiredSampler(const CStr& samplerName); void AddRequiredSampler(RequiredSampler sampler);
const std::vector<CStrIntern>& GetRequiredSampler() const { return m_RequiredSamplers; } const std::vector<RequiredSampler>& GetRequiredSamplers() const { return m_RequiredSamplers; }
private: private:
@ -92,7 +98,7 @@ private:
CTexturePtr m_DiffuseTexture; CTexturePtr m_DiffuseTexture;
SamplersVector m_Samplers; SamplersVector m_Samplers;
std::vector<CStrIntern> m_RequiredSamplers; std::vector<RequiredSampler> m_RequiredSamplers;
std::array<CStrIntern, static_cast<size_t>(Pass::COUNT)> m_ShaderEffects; std::array<CStrIntern, static_cast<size_t>(Pass::COUNT)> m_ShaderEffects;
CShaderDefines m_ShaderDefines; CShaderDefines m_ShaderDefines;

View file

@ -103,6 +103,7 @@ CMaterial CMaterialManager::LoadMaterial(const VfsPath& pathname)
AT(material); AT(material);
AT(name); AT(name);
AT(pass); AT(pass);
AT(srgb);
AT(value); AT(value);
#undef AT #undef AT
#undef EL #undef EL
@ -161,7 +162,8 @@ CMaterial CMaterialManager::LoadMaterial(const VfsPath& pathname)
} }
else if (token == el_required_texture) else if (token == el_required_texture)
{ {
material.AddRequiredSampler(attrs.GetNamedItem(at_name)); const bool sRGB{attrs.GetNamedItem(at_srgb) == "true"};
material.AddRequiredSampler({CStrIntern{attrs.GetNamedItem(at_name)}, sRGB});
if (!attrs.GetNamedItem(at_define).empty()) if (!attrs.GetNamedItem(at_define).empty())
material.AddShaderDefine(CStrIntern(attrs.GetNamedItem(at_define)), str_1); material.AddShaderDefine(CStrIntern(attrs.GetNamedItem(at_define)), str_1);
} }

View file

@ -1,4 +1,4 @@
/* Copyright (C) 2025 Wildfire Games. /* Copyright (C) 2026 Wildfire Games.
* This file is part of 0 A.D. * This file is part of 0 A.D.
* *
* 0 A.D. is free software: you can redistribute it and/or modify * 0 A.D. is free software: you can redistribute it and/or modify
@ -69,7 +69,7 @@ CObjectBase::CObjectBase(CObjectManager& objectManager, CActorDef& actorDef, u8
m_Properties.m_FloatOnWater = false; m_Properties.m_FloatOnWater = false;
// Remove leading art/actors/ & include quality level. // Remove leading art/actors/ & include quality level.
m_Identifier = m_ActorDef.m_Pathname.string8().substr(11) + CStr::FromInt(m_QualityLevel); m_Identifier = fmt::format("{}{}", m_ActorDef.m_Pathname.string8().substr(11), m_QualityLevel);
} }
std::unique_ptr<CObjectBase> CObjectBase::CopyWithQuality(u8 newQualityLevel) const std::unique_ptr<CObjectBase> CObjectBase::CopyWithQuality(u8 newQualityLevel) const

View file

@ -51,6 +51,22 @@
#include <sstream> #include <sstream>
#include <utility> #include <utility>
namespace
{
bool ShouldInterpretTextureAsSRGB(const CObjectBase::Samp& sampler, const CMaterial& material)
{
const auto requiredSamplers{material.GetRequiredSamplers()};
auto it{std::find_if(requiredSamplers.begin(), requiredSamplers.end(),
[&](const CMaterial::RequiredSampler& requiredSampler)
{
return requiredSampler.name == sampler.m_SamplerName;
})};
return it != requiredSamplers.end() && it->sRGB;
}
}
CObjectEntry::CObjectEntry(const std::shared_ptr<CObjectBase>& base, const CSimulation2& simulation) : CObjectEntry::CObjectEntry(const std::shared_ptr<CObjectBase>& base, const CSimulation2& simulation) :
m_Base(base), m_Color(1.0f, 1.0f, 1.0f, 1.0f), m_Simulation(simulation) m_Base(base), m_Color(1.0f, 1.0f, 1.0f, 1.0f), m_Simulation(simulation)
{ {
@ -155,6 +171,7 @@ bool CObjectEntry::BuildVariation(const std::vector<const std::set<CStr>*>& comp
{ {
CTextureProperties textureProps(samp.m_SamplerFile); CTextureProperties textureProps(samp.m_SamplerFile);
textureProps.SetAddressMode(Renderer::Backend::Sampler::AddressMode::CLAMP_TO_EDGE); textureProps.SetAddressMode(Renderer::Backend::Sampler::AddressMode::CLAMP_TO_EDGE);
textureProps.SetSRGB(ShouldInterpretTextureAsSRGB(samp, material));
CTexturePtr texture = g_Renderer.GetTextureManager().CreateTexture(textureProps); CTexturePtr texture = g_Renderer.GetTextureManager().CreateTexture(textureProps);
// if we've loaded this model we're probably going to render it soon, so prefetch its texture. // if we've loaded this model we're probably going to render it soon, so prefetch its texture.
// All textures are prefetched even in the fixed pipeline, including the normal maps etc. // All textures are prefetched even in the fixed pipeline, including the normal maps etc.
@ -167,11 +184,14 @@ bool CObjectEntry::BuildVariation(const std::vector<const std::set<CStr>*>& comp
CModel* model = newModel.get(); CModel* model = newModel.get();
m_Model = std::move(newModel); m_Model = std::move(newModel);
for (const CStrIntern& requSampName : model->GetMaterial().GetRequiredSampler()) for (const auto& requiredSampler : model->GetMaterial().GetRequiredSamplers())
{ {
if (std::find_if(m_Samplers.begin(), m_Samplers.end(), if (std::find_if(m_Samplers.begin(), m_Samplers.end(),
[&](const CObjectBase::Samp& sampler) { return sampler.m_SamplerName == requSampName; }) == m_Samplers.end()) [&](const CObjectBase::Samp& sampler) { return sampler.m_SamplerName == requiredSampler.name; }) == m_Samplers.end())
LOGERROR("Actor %s: required texture sampler %s not found (material %s)", m_Base->GetIdentifier(), requSampName.string().c_str(), m_Base->m_Material.string8().c_str()); {
LOGERROR("Actor %s: required texture sampler %s not found (material %s)",
m_Base->GetIdentifier(), requiredSampler.name.string().c_str(), m_Base->m_Material.string8().c_str());
}
} }
// calculate initial object space bounds, based on vertex positions // calculate initial object space bounds, based on vertex positions

View file

@ -1,4 +1,4 @@
/* Copyright (C) 2025 Wildfire Games. /* Copyright (C) 2026 Wildfire Games.
* This file is part of 0 A.D. * This file is part of 0 A.D.
* *
* 0 A.D. is free software: you can redistribute it and/or modify * 0 A.D. is free software: you can redistribute it and/or modify
@ -156,7 +156,8 @@ bool ResolveIncludesImpl(
it = includeCache.emplace(path, std::move(includeContent)).first; it = includeCache.emplace(path, std::move(includeContent)).first;
} }
// We need to insert #line directives to have correct line numbers in errors. // We need to insert #line directives to have correct line numbers in errors.
chunks.emplace_back(lineDirective + "1\n" + it->second + "\n" + lineDirective + CStr::FromUInt(line + 1) + "\n"); chunks.emplace_back(fmt::format("{}1\n{}\n{}{}\n", lineDirective, it->second, lineDirective,
line + 1));
processedParts.emplace_back(currentPart.substr(0, lineStart)); processedParts.emplace_back(currentPart.substr(0, lineStart));
if (!ResolveIncludesImpl(chunks.back(), includeCache, includeCallback, chunks, processedParts)) if (!ResolveIncludesImpl(chunks.back(), includeCache, includeCallback, chunks, processedParts))
return false; return false;

View file

@ -1,4 +1,4 @@
/* Copyright (C) 2025 Wildfire Games. /* Copyright (C) 2026 Wildfire Games.
* This file is part of 0 A.D. * This file is part of 0 A.D.
* *
* 0 A.D. is free software: you can redistribute it and/or modify * 0 A.D. is free software: you can redistribute it and/or modify
@ -60,7 +60,7 @@ namespace
{ {
Renderer::Backend::Format ChooseFormatAndTransformTextureDataIfNeeded( Renderer::Backend::Format ChooseFormatAndTransformTextureDataIfNeeded(
Renderer::Backend::IDevice* device, Tex& textureData, const bool hasS3TC) Renderer::Backend::IDevice* device, Tex& textureData, const bool hasS3TC, const bool sRGB)
{ {
const bool alpha = (textureData.m_Flags & TEX_ALPHA) != 0; const bool alpha = (textureData.m_Flags & TEX_ALPHA) != 0;
const bool grey = (textureData.m_Flags & TEX_GREY) != 0; const bool grey = (textureData.m_Flags & TEX_GREY) != 0;
@ -81,13 +81,13 @@ Renderer::Backend::Format ChooseFormatAndTransformTextureDataIfNeeded(
switch (dxt) switch (dxt)
{ {
case DXT1A: case DXT1A:
return Renderer::Backend::Format::BC1_RGBA_UNORM; return sRGB ? Renderer::Backend::Format::BC1_RGBA_SRGB : Renderer::Backend::Format::BC1_RGBA_UNORM;
case 1: case 1:
return Renderer::Backend::Format::BC1_RGB_UNORM; return sRGB ? Renderer::Backend::Format::BC1_RGB_SRGB : Renderer::Backend::Format::BC1_RGB_UNORM;
case 3: case 3:
return Renderer::Backend::Format::BC2_UNORM; return sRGB ? Renderer::Backend::Format::BC2_SRGB : Renderer::Backend::Format::BC2_UNORM;
case 5: case 5:
return Renderer::Backend::Format::BC3_UNORM; return sRGB ? Renderer::Backend::Format::BC3_SRGB : Renderer::Backend::Format::BC3_UNORM;
default: default:
LOGERROR("Unknown DXT compression."); LOGERROR("Unknown DXT compression.");
return Renderer::Backend::Format::UNDEFINED; return Renderer::Backend::Format::UNDEFINED;
@ -101,9 +101,12 @@ Renderer::Backend::Format ChooseFormatAndTransformTextureDataIfNeeded(
{ {
case 8: case 8:
ENSURE(grey); ENSURE(grey);
ENSURE(!sRGB);
return Renderer::Backend::Format::L8_UNORM; return Renderer::Backend::Format::L8_UNORM;
case 24: case 24:
ENSURE(grey);
ENSURE(!alpha); ENSURE(!alpha);
ENSURE(!sRGB);
if (device->IsTextureFormatSupported(Renderer::Backend::Format::R8G8B8_UNORM)) if (device->IsTextureFormatSupported(Renderer::Backend::Format::R8G8B8_UNORM))
return Renderer::Backend::Format::R8G8B8_UNORM; return Renderer::Backend::Format::R8G8B8_UNORM;
else else
@ -114,7 +117,7 @@ Renderer::Backend::Format ChooseFormatAndTransformTextureDataIfNeeded(
} }
case 32: case 32:
ENSURE(alpha); ENSURE(alpha);
return Renderer::Backend::Format::R8G8B8A8_UNORM; return sRGB ? Renderer::Backend::Format::R8G8B8A8_SRGB : Renderer::Backend::Format::R8G8B8A8_UNORM;
default: default:
LOGERROR("Unsupported BPP: %zu", textureData.m_Bpp); LOGERROR("Unsupported BPP: %zu", textureData.m_Bpp);
} }
@ -336,6 +339,7 @@ struct TPhash
hash_combine(seed, textureProperties.m_AnisotropicFilterEnabled); hash_combine(seed, textureProperties.m_AnisotropicFilterEnabled);
hash_combine(seed, textureProperties.m_FormatOverride); hash_combine(seed, textureProperties.m_FormatOverride);
hash_combine(seed, textureProperties.m_IgnoreQuality); hash_combine(seed, textureProperties.m_IgnoreQuality);
hash_combine(seed, textureProperties.m_SRGB);
return seed; return seed;
} }
@ -358,7 +362,8 @@ struct TPequal_to
lhs.m_AddressModeV == rhs.m_AddressModeV && lhs.m_AddressModeV == rhs.m_AddressModeV &&
lhs.m_AnisotropicFilterEnabled == rhs.m_AnisotropicFilterEnabled && lhs.m_AnisotropicFilterEnabled == rhs.m_AnisotropicFilterEnabled &&
lhs.m_FormatOverride == rhs.m_FormatOverride && lhs.m_FormatOverride == rhs.m_FormatOverride &&
lhs.m_IgnoreQuality == rhs.m_IgnoreQuality; lhs.m_IgnoreQuality == rhs.m_IgnoreQuality &&
lhs.m_SRGB == rhs.m_SRGB;
} }
bool operator()(const CTexturePtr& lhs, const CTexturePtr& rhs) const bool operator()(const CTexturePtr& lhs, const CTexturePtr& rhs) const
@ -526,7 +531,8 @@ public:
} }
else else
{ {
format = ChooseFormatAndTransformTextureDataIfNeeded(m_Device, textureData, m_HasS3TC); format = ChooseFormatAndTransformTextureDataIfNeeded(
m_Device, textureData, m_HasS3TC, texture->m_Properties.m_SRGB);
} }
if (format == Renderer::Backend::Format::UNDEFINED) if (format == Renderer::Backend::Format::UNDEFINED)

View file

@ -1,4 +1,4 @@
/* Copyright (C) 2025 Wildfire Games. /* Copyright (C) 2026 Wildfire Games.
* This file is part of 0 A.D. * This file is part of 0 A.D.
* *
* 0 A.D. is free software: you can redistribute it and/or modify * 0 A.D. is free software: you can redistribute it and/or modify
@ -245,6 +245,8 @@ public:
void SetIgnoreQuality(bool ignore) { m_IgnoreQuality = ignore; } void SetIgnoreQuality(bool ignore) { m_IgnoreQuality = ignore; }
void SetSRGB(const bool sRGB) { m_SRGB = sRGB; }
private: private:
// Must update TPhash, TPequal_to when changing these fields // Must update TPhash, TPequal_to when changing these fields
VfsPath m_Path; VfsPath m_Path;
@ -257,6 +259,7 @@ private:
Renderer::Backend::Format m_FormatOverride = Renderer::Backend::Format m_FormatOverride =
Renderer::Backend::Format::UNDEFINED; Renderer::Backend::Format::UNDEFINED;
bool m_IgnoreQuality = false; bool m_IgnoreQuality = false;
bool m_SRGB{false};
}; };
/** /**

View file

@ -755,7 +755,7 @@ void CGUI::Xeromyces_ReadObject(const XMBData& xmb, XMBElement element, IGUIObje
// Check if name isn't set, generate an internal name in that case. // Check if name isn't set, generate an internal name in that case.
if (!NameSet) if (!NameSet)
{ {
object->SetName("__internal(" + CStr::FromInt(m_InternalNameNumber) + ")"); object->SetName(fmt::format("__internal({})", m_InternalNameNumber));
++m_InternalNameNumber; ++m_InternalNameNumber;
} }
@ -977,7 +977,7 @@ void CGUI::Xeromyces_ReadRepeat(const XMBData& xmb, XMBElement element, IGUIObje
for (int n = 0; n < count; ++n) for (int n = 0; n < count; ++n)
{ {
NameSubst.emplace_back(var, "[" + CStr::FromInt(n) + "]"); NameSubst.emplace_back(var, fmt::format("[{}]", n));
XERO_ITER_EL(element, child) XERO_ITER_EL(element, child)
{ {

View file

@ -1,4 +1,4 @@
/* Copyright (C) 2025 Wildfire Games. /* Copyright (C) 2026 Wildfire Games.
* This file is part of 0 A.D. * This file is part of 0 A.D.
* *
* 0 A.D. is free software: you can redistribute it and/or modify * 0 A.D. is free software: you can redistribute it and/or modify
@ -52,114 +52,111 @@ CVector3D GetPositionOnCubic(const CVector3D& startPos, const CVector3D& startVe
/*********************************** R N S **************************************************/ /*********************************** R N S **************************************************/
RNSpline::RNSpline() RNSpline::RNSpline() = default;
: NodeCount(0)
{
}
RNSpline::~RNSpline() = default; RNSpline::~RNSpline() = default;
// adds node and updates segment length // adds node and updates segment length
void RNSpline::AddNode(const CFixedVector3D& pos) void RNSpline::AddNode(const CFixedVector3D& pos)
{ {
if (NodeCount >= MAX_SPLINE_NODES) if (m_NodeCount >= MAX_SPLINE_NODES)
return; return;
if (NodeCount == 0) if (m_NodeCount == 0)
MaxDistance = fixed::Zero(); m_MaxDistance = fixed::Zero();
else else
{ {
Node[NodeCount-1].Distance = (Node[NodeCount-1].Position - pos).Length(); m_Nodes[m_NodeCount-1].Distance = (m_Nodes[m_NodeCount-1].Position - pos).Length();
MaxDistance += Node[NodeCount-1].Distance; m_MaxDistance += m_Nodes[m_NodeCount-1].Distance;
} }
SplineData temp; SplineData temp;
temp.Position = pos; temp.Position = pos;
Node.push_back(temp); m_Nodes.push_back(temp);
++NodeCount; ++m_NodeCount;
} }
// called after all nodes added. This function calculates the node velocities // called after all nodes added. This function calculates the node velocities
void RNSpline::BuildSpline() void RNSpline::BuildSpline()
{ {
if (NodeCount == 2) if (m_NodeCount == 2)
{ {
Node[0].Velocity = GetStartVelocity(0); m_Nodes[0].Velocity = GetStartVelocity(0);
Node[NodeCount-1].Velocity = GetEndVelocity(NodeCount-1); m_Nodes[m_NodeCount-1].Velocity = GetEndVelocity(m_NodeCount-1);
return; return;
} }
else if (NodeCount < 2) else if (m_NodeCount < 2)
return; return;
for (int i = 1; i < NodeCount-1; ++i) for (int i = 1; i < m_NodeCount-1; ++i)
{ {
CVector3D Next = Node[i+1].Position - Node[i].Position; CVector3D Next = m_Nodes[i+1].Position - m_Nodes[i].Position;
CVector3D Previous = Node[i-1].Position - Node[i].Position; CVector3D Previous = m_Nodes[i-1].Position - m_Nodes[i].Position;
Next.Normalize(); Next.Normalize();
Previous.Normalize(); Previous.Normalize();
// split the angle (figure 4) // split the angle (figure 4)
Node[i].Velocity = Next - Previous; m_Nodes[i].Velocity = Next - Previous;
Node[i].Velocity.Normalize(); m_Nodes[i].Velocity.Normalize();
} }
// calculate start and end velocities // calculate start and end velocities
Node[0].Velocity = GetStartVelocity(0); m_Nodes[0].Velocity = GetStartVelocity(0);
Node[NodeCount-1].Velocity = GetEndVelocity(NodeCount-1); m_Nodes[m_NodeCount-1].Velocity = GetEndVelocity(m_NodeCount-1);
} }
// spline access function. time is 0 -> 1 // spline access function. time is 0 -> 1
CVector3D RNSpline::GetPosition(float time) const CVector3D RNSpline::GetPosition(float time) const
{ {
if (NodeCount < 2) if (m_NodeCount < 2)
return CVector3D(0.0f, 0.0f, 0.0f); return CVector3D(0.0f, 0.0f, 0.0f);
if (time < 0.0f) if (time < 0.0f)
time = 0.0f; time = 0.0f;
if (time > 1.0f) if (time > 1.0f)
time = 1.0f; time = 1.0f;
float Distance = time * MaxDistance.ToFloat(); float Distance = time * m_MaxDistance.ToFloat();
float CurrentDistance = 0.f; float CurrentDistance = 0.f;
int i = 0; int i = 0;
// Find which node we're on // Find which node we're on
while (CurrentDistance + Node[i].Distance.ToFloat() < Distance && i < NodeCount - 2) while (CurrentDistance + m_Nodes[i].Distance.ToFloat() < Distance && i < m_NodeCount - 2)
{ {
CurrentDistance += Node[i].Distance.ToFloat(); CurrentDistance += m_Nodes[i].Distance.ToFloat();
++i; ++i;
} }
ENSURE(i < NodeCount - 1); ENSURE(i < m_NodeCount - 1);
float t = Distance - CurrentDistance; float t = Distance - CurrentDistance;
// TODO: reimplement CVector3D comparator (float comparing is bad without EPS) // TODO: reimplement CVector3D comparator (float comparing is bad without EPS)
if (Node[i].Position == Node[i+1].Position || Node[i].Distance.ToFloat() < 1e-7) // distance too small or zero if (m_Nodes[i].Position == m_Nodes[i+1].Position || m_Nodes[i].Distance.ToFloat() < 1e-7) // distance too small or zero
{ {
return Node[i+1].Position; return m_Nodes[i+1].Position;
} }
t /= Node[i].Distance.ToFloat(); // scale t in range 0 - 1 t /= m_Nodes[i].Distance.ToFloat(); // scale t in range 0 - 1
CVector3D startVel = Node[i].Velocity * Node[i].Distance.ToFloat(); CVector3D startVel = m_Nodes[i].Velocity * m_Nodes[i].Distance.ToFloat();
CVector3D endVel = Node[i+1].Velocity * Node[i].Distance.ToFloat(); CVector3D endVel = m_Nodes[i+1].Velocity * m_Nodes[i].Distance.ToFloat();
return GetPositionOnCubic(Node[i].Position, startVel, return GetPositionOnCubic(m_Nodes[i].Position, startVel,
Node[i+1].Position, endVel, t); m_Nodes[i+1].Position, endVel, t);
} }
const std::vector<SplineData>& RNSpline::GetAllNodes() const const std::vector<SplineData>& RNSpline::GetAllNodes() const
{ {
return Node; return m_Nodes;
} }
// internal. Based on Equation 14 // internal. Based on Equation 14
CVector3D RNSpline::GetStartVelocity(int index) CVector3D RNSpline::GetStartVelocity(int index)
{ {
if (index >= NodeCount - 1 || index < 0) if (index >= m_NodeCount - 1 || index < 0)
return CVector3D(0.0f, 0.0f, 0.0f); return CVector3D(0.0f, 0.0f, 0.0f);
CVector3D temp = CVector3D(Node[index+1].Position - Node[index].Position) * 3.0f * (1.0f / Node[index].Distance.ToFloat()); CVector3D temp = CVector3D(m_Nodes[index+1].Position - m_Nodes[index].Position) * 3.0f * (1.0f / m_Nodes[index].Distance.ToFloat());
return (temp - Node[index+1].Velocity)*0.5f; return (temp - m_Nodes[index+1].Velocity)*0.5f;
} }
// internal. Based on Equation 15 // internal. Based on Equation 15
CVector3D RNSpline::GetEndVelocity(int index) CVector3D RNSpline::GetEndVelocity(int index)
{ {
if (index >= NodeCount || index < 1) if (index >= m_NodeCount || index < 1)
return CVector3D(0.0f, 0.0f, 0.0f); return CVector3D(0.0f, 0.0f, 0.0f);
CVector3D temp = CVector3D(Node[index].Position - Node[index-1].Position) * 3.0f * (1.0f / Node[index-1].Distance.ToFloat()); CVector3D temp = CVector3D(m_Nodes[index].Position - m_Nodes[index-1].Position) * 3.0f * (1.0f / m_Nodes[index-1].Distance.ToFloat());
return (temp - Node[index-1].Velocity) * 0.5f; return (temp - m_Nodes[index-1].Velocity) * 0.5f;
} }
/*********************************** S N S **************************************************/ /*********************************** S N S **************************************************/
@ -176,21 +173,21 @@ void SNSpline::BuildSpline()
// smoothing filter. // smoothing filter.
void SNSpline::Smooth() void SNSpline::Smooth()
{ {
if (NodeCount < 3) if (m_NodeCount < 3)
return; return;
CVector3D newVel; CVector3D newVel;
CVector3D oldVel = GetStartVelocity(0); CVector3D oldVel = GetStartVelocity(0);
for (int i = 1; i < NodeCount-1; ++i) for (int i = 1; i < m_NodeCount-1; ++i)
{ {
// Equation 12 // Equation 12
newVel = GetEndVelocity(i) * Node[i].Distance.ToFloat() + GetStartVelocity(i) * Node[i-1].Distance.ToFloat(); newVel = GetEndVelocity(i) * m_Nodes[i].Distance.ToFloat() + GetStartVelocity(i) * m_Nodes[i-1].Distance.ToFloat();
newVel = newVel * (1 / (Node[i-1].Distance + Node[i].Distance).ToFloat()); newVel = newVel * (1 / (m_Nodes[i-1].Distance + m_Nodes[i].Distance).ToFloat());
Node[i-1].Velocity = oldVel; m_Nodes[i-1].Velocity = oldVel;
oldVel = newVel; oldVel = newVel;
} }
Node[NodeCount-1].Velocity = GetEndVelocity(NodeCount-1); m_Nodes[m_NodeCount-1].Velocity = GetEndVelocity(m_NodeCount-1);
Node[NodeCount-2].Velocity = oldVel; m_Nodes[m_NodeCount-2].Velocity = oldVel;
} }
/*********************************** T N S **************************************************/ /*********************************** T N S **************************************************/
@ -201,15 +198,15 @@ TNSpline::~TNSpline() = default;
// ie time period is time from last node to this node // ie time period is time from last node to this node
void TNSpline::AddNode(const CFixedVector3D& pos, const CFixedVector3D& rotation, fixed timePeriod) void TNSpline::AddNode(const CFixedVector3D& pos, const CFixedVector3D& rotation, fixed timePeriod)
{ {
if (NodeCount >= MAX_SPLINE_NODES) if (m_NodeCount >= MAX_SPLINE_NODES)
return; return;
if (NodeCount == 0) if (m_NodeCount == 0)
MaxDistance = fixed::Zero(); m_MaxDistance = fixed::Zero();
else else
{ {
Node[NodeCount-1].Distance = timePeriod; m_Nodes[m_NodeCount-1].Distance = timePeriod;
MaxDistance += Node[NodeCount-1].Distance; m_MaxDistance += m_Nodes[m_NodeCount-1].Distance;
} }
SplineData temp; SplineData temp;
@ -219,56 +216,56 @@ void TNSpline::AddNode(const CFixedVector3D& pos, const CFixedVector3D& rotation
temp.Distance = fixed::Zero(); temp.Distance = fixed::Zero();
temp.Velocity = CVector3D(0.0f, 0.0f, 0.0f); temp.Velocity = CVector3D(0.0f, 0.0f, 0.0f);
temp.Rotation = rotation; temp.Rotation = rotation;
Node.push_back(temp); m_Nodes.push_back(temp);
++NodeCount; ++m_NodeCount;
} }
//Inserts node before position //Inserts node before position
void TNSpline::InsertNode(const int index, const CFixedVector3D& pos, const CFixedVector3D& /*rotation*/, void TNSpline::InsertNode(const int index, const CFixedVector3D& pos, const CFixedVector3D& /*rotation*/,
fixed timePeriod) fixed timePeriod)
{ {
if (NodeCount >= MAX_SPLINE_NODES || index < 0 || index > NodeCount) if (m_NodeCount >= MAX_SPLINE_NODES || index < 0 || index > m_NodeCount)
return; return;
if (NodeCount == 0) if (m_NodeCount == 0)
MaxDistance = fixed::Zero(); m_MaxDistance = fixed::Zero();
else else
MaxDistance += timePeriod; m_MaxDistance += timePeriod;
SplineData temp; SplineData temp;
temp.Position = pos; temp.Position = pos;
temp.Distance = timePeriod; temp.Distance = timePeriod;
Node.insert(Node.begin() + index, temp); m_Nodes.insert(m_Nodes.begin() + index, temp);
if (index > 0) if (index > 0)
std::swap(Node[index].Distance, Node[index - 1].Distance); std::swap(m_Nodes[index].Distance, m_Nodes[index - 1].Distance);
++NodeCount; ++m_NodeCount;
} }
//Removes node at index //Removes node at index
void TNSpline::RemoveNode(const int index) void TNSpline::RemoveNode(const int index)
{ {
if (NodeCount == 0 || index > NodeCount - 1) if (m_NodeCount == 0 || index > m_NodeCount - 1)
return; return;
MaxDistance -= Node[index].Distance; m_MaxDistance -= m_Nodes[index].Distance;
Node.erase(Node.begin() + index); m_Nodes.erase(m_Nodes.begin() + index);
--NodeCount; --m_NodeCount;
} }
void TNSpline::UpdateNodeTime(const int index, fixed time) void TNSpline::UpdateNodeTime(const int index, fixed time)
{ {
if (NodeCount == 0 || index > NodeCount - 1) if (m_NodeCount == 0 || index > m_NodeCount - 1)
return; return;
Node[index].Distance = time; m_Nodes[index].Distance = time;
} }
void TNSpline::UpdateNodePos(const int index, const CFixedVector3D& pos) void TNSpline::UpdateNodePos(const int index, const CFixedVector3D& pos)
{ {
if (NodeCount == 0 || index > NodeCount - 1) if (m_NodeCount == 0 || index > m_NodeCount - 1)
return; return;
Node[index].Position = pos; m_Nodes[index].Position = pos;
} }
void TNSpline::BuildSpline() void TNSpline::BuildSpline()
@ -289,15 +286,15 @@ void TNSpline::Smooth()
void TNSpline::Constrain() void TNSpline::Constrain()
{ {
if (NodeCount < 3) if (m_NodeCount < 3)
return; return;
for (int i = 1; i < NodeCount-1; ++i) for (int i = 1; i < m_NodeCount-1; ++i)
{ {
// Equation 13 // Equation 13
float r0 = (Node[i].Position - Node[i - 1].Position).Length().ToFloat() / Node[i-1].Distance.ToFloat(); float r0 = (m_Nodes[i].Position - m_Nodes[i - 1].Position).Length().ToFloat() / m_Nodes[i-1].Distance.ToFloat();
float r1 = (Node[i+1].Position - Node[i].Position).Length().ToFloat() / Node[i].Distance.ToFloat(); float r1 = (m_Nodes[i+1].Position - m_Nodes[i].Position).Length().ToFloat() / m_Nodes[i].Distance.ToFloat();
Node[i].Velocity *= 4.0f*r0*r1 / ((r0 + r1)*(r0 + r1)); m_Nodes[i].Velocity *= 4.0f*r0*r1 / ((r0 + r1)*(r0 + r1));
} }
} }

View file

@ -1,4 +1,4 @@
/* Copyright (C) 2025 Wildfire Games. /* Copyright (C) 2026 Wildfire Games.
* This file is part of 0 A.D. * This file is part of 0 A.D.
* *
* 0 A.D. is free software: you can redistribute it and/or modify * 0 A.D. is free software: you can redistribute it and/or modify
@ -63,12 +63,12 @@ public:
CVector3D GetRotation(float time) const; CVector3D GetRotation(float time) const;
const std::vector<SplineData>& GetAllNodes() const; const std::vector<SplineData>& GetAllNodes() const;
fixed MaxDistance; fixed GetMaxDistance() const { return m_MaxDistance; }
int NodeCount;
protected: protected:
fixed m_MaxDistance;
std::vector<SplineData> Node; int m_NodeCount{0};
std::vector<SplineData> m_Nodes;
CVector3D GetStartVelocity(int index); CVector3D GetStartVelocity(int index);
CVector3D GetEndVelocity(int index); CVector3D GetEndVelocity(int index);
}; };

View file

@ -72,7 +72,7 @@ CNetClient::CNetClient(CGame* game, std::string serverAddressOrHostname, std::ui
CNetClient{PrivateTag{}, game, std::move(serverAddressOrHostname), serverPort, username, hostJID, CNetClient{PrivateTag{}, game, std::move(serverAddressOrHostname), serverPort, username, hostJID,
std::move(hashedPassword), std::move(controllerSecret)} std::move(hashedPassword), std::move(controllerSecret)}
{ {
SetupConnection(nullptr); SetupConnection();
} }
CNetClient::CNetClient(PrivateTag, CGame* game, std::string serverAddressOrHostname, CNetClient::CNetClient(PrivateTag, CGame* game, std::string serverAddressOrHostname,
@ -169,10 +169,10 @@ CNetClient::~CNetClient()
} }
void CNetClient::SetupConnection(ENetHost* enetClient) void CNetClient::SetupConnection()
{ {
CNetClientSession* session = new CNetClientSession(*this); CNetClientSession* session = new CNetClientSession(*this);
bool ok = session->Connect(m_ServerAddressOrHostname, m_ServerPort, enetClient); bool ok = session->Connect(m_ServerAddressOrHostname, m_ServerPort);
SetAndOwnSession(session); SetAndOwnSession(session);
if (ok) if (ok)
m_PollingThread = std::thread(Threading::HandleExceptions<CNetClientSession::RunNetLoop>::Wrapper, m_Session); m_PollingThread = std::thread(Threading::HandleExceptions<CNetClientSession::RunNetLoop>::Wrapper, m_Session);
@ -284,7 +284,7 @@ bool CNetClient::TryToConnectWithSTUN(std::string serverAddressOrHostname, std::
try try
{ {
g_NetClient->SetupConnection(enetClient); g_NetClient->SetupConnection();
} }
catch (...) catch (...)
{ {
@ -463,6 +463,7 @@ bool CNetClient::SendMessage(const CNetMessage* message)
void CNetClient::HandleConnect() void CNetClient::HandleConnect()
{ {
LOGMESSAGE("Net client: Connected", m_ServerAddressOrHostname, m_ServerPort);
Update((uint)NMT_CONNECT_COMPLETE, NULL); Update((uint)NMT_CONNECT_COMPLETE, NULL);
} }

View file

@ -289,7 +289,7 @@ private:
* Set up a connection to the remote networked server. * Set up a connection to the remote networked server.
* @return true on success, false on connection failure * @return true on success, false on connection failure
*/ */
void SetupConnection(ENetHost* enetClient); void SetupConnection();
/** /**
* Take ownership of a session object, and use it for all network communication. * Take ownership of a session object, and use it for all network communication.

View file

@ -22,8 +22,8 @@
#include "lib/code_generation.h" #include "lib/code_generation.h"
#include "lib/debug.h" #include "lib/debug.h"
#include "network/NetClient.h" #include "network/NetClient.h"
#include "network/NetEnet.h"
#include "network/NetMessage.h" #include "network/NetMessage.h"
#include "network/NetProtocol.h"
#include "network/NetStats.h" #include "network/NetStats.h"
#include "ps/CLogger.h" #include "ps/CLogger.h"
#include "ps/ProfileViewer.h" #include "ps/ProfileViewer.h"
@ -32,43 +32,405 @@
constexpr int NETCLIENT_POLL_TIMEOUT = 50; constexpr int NETCLIENT_POLL_TIMEOUT = 50;
constexpr int CHANNEL_COUNT = 1; #include <time.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>
#include <arpa/inet.h>
#include <string.h>
#include <stdio.h>
#include <errno.h>
#include <poll.h>
#include <ngtcp2/ngtcp2.h>
#include <ngtcp2/ngtcp2_crypto.h>
#include <ngtcp2/ngtcp2_crypto_gnutls.h>
#include <gnutls/crypto.h>
#include <gnutls/gnutls.h>
struct CNetClientSession::Quic
{
AddressStorage localAddress;
std::unique_ptr<gnutls_certificate_credentials_st, CredentialsDeleter> credentials;
std::unique_ptr<gnutls_session_int, SessionDeleter> session;
std::unique_ptr<ngtcp2_conn, ConnectionDeleter> quicConnection;
ngtcp2_crypto_conn_ref connectionReference;
int fd;
std::optional<Stream> streams;
};
namespace
{
struct CreateSocketResult
{
int descriptor;
AddressStorage address;
};
CreateSocketResult CreateSocket(const char* host, const std::uint16_t port)
{
addrinfo hints{};
hints.ai_flags = AF_UNSPEC;
hints.ai_socktype = SOCK_DGRAM;
addrinfo* res;
const int rv{getaddrinfo(host, fmt::format("{}", port).c_str(), &hints, &res)};
if (rv)
throw std::runtime_error{fmt::format("getaddrinfo: {}", gai_strerror(rv))};
std::unique_ptr<addrinfo, decltype(&freeaddrinfo)> infoList{res, &freeaddrinfo};
addrinfo* rp;
int fd{-1};
for (rp = res; rp; rp = rp->ai_next)
{
fd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol);
if (fd != -1)
break;
}
if (fd == -1)
throw std::runtime_error{"unable to create a socket"};
CreateSocketResult result{
.descriptor{fd},
.address{.length{rp->ai_addrlen}}
};
if (rp->ai_family == AF_INET6)
result.address.address.in6 = reinterpret_cast<ngtcp2_sockaddr_in6&>(*rp->ai_addr);
else
result.address.address.in = reinterpret_cast<ngtcp2_sockaddr_in&>(*rp->ai_addr);
return result;
}
AddressStorage ConnectSocket(const int fd, const AddressStorage& remoteAddress)
{
if (connect(fd, &remoteAddress.address.sa, remoteAddress.length))
throw std::runtime_error{fmt::format("connect: {}", strerror(errno))};
ngtcp2_sockaddr_union localAddress;
ngtcp2_socklen localAddressLength{sizeof(localAddress)};
if (getsockname(fd, &localAddress.sa, &localAddressLength) == -1)
throw std::runtime_error{fmt::format("getsockname: {}", strerror(errno))};
return {localAddress, localAddressLength};
}
void ClientGnutlsInit(CNetClientSession::Quic* c)
{
gnutls_certificate_credentials_t tempCred;
const int allocRet{gnutls_certificate_allocate_credentials(&tempCred)};
if (allocRet)
{
throw std::runtime_error{fmt::format("cred init failed: {}: {}", allocRet,
gnutls_strerror(allocRet))};
}
c->credentials.reset(tempCred);
gnutls_session_t tempSession;
if (const int initRet{gnutls_init(&tempSession, GNUTLS_CLIENT | GNUTLS_ENABLE_EARLY_DATA |
GNUTLS_NO_END_OF_EARLY_DATA)})
{
throw std::runtime_error{fmt::format("gnutls_init: {}", gnutls_strerror(initRet))};
}
c->session.reset(tempSession);
if (ngtcp2_crypto_gnutls_configure_client_session(c->session.get()))
throw std::runtime_error{"ngtcp2_crypto_gnutls_configure_client_session failed"};
if (const int priorityRet{gnutls_priority_set_direct(c->session.get(), TLS_PRIORITY, nullptr)})
{
throw std::runtime_error{fmt::format("gnutls_priority_set_direct: {}",
gnutls_strerror(priorityRet))};
}
gnutls_session_set_ptr(c->session.get(), &c->connectionReference);
if (const int setRet{gnutls_credentials_set(c->session.get(), GNUTLS_CRD_CERTIFICATE,
c->credentials.get())})
{
throw std::runtime_error{fmt::format("gnutls_credentials_set: {}", gnutls_strerror(setRet))};
}
}
int OpenStream(ngtcp2_conn*, const std::int64_t streamId, void* userData)
{
CNetClientSession& session{*static_cast<CNetClientSession*>(userData)};
session.m_Connected = true;
session.m_WasConnected = true;
session.m_Quic->streams.emplace(streamId);
session.m_IncomingMessages.push(CNetClientSession::ConnectionEstablished{});
return 0;
}
int OnStreamDataReceive(ngtcp2_conn* conn, const std::uint32_t /*flags*/, const std::int64_t streamId,
const std::size_t /*offset*/, const std::uint8_t* data, std::size_t dataSize, void* userData, void*)
{
auto& session = *static_cast<CNetClientSession*>(userData);
auto message = session.m_Quic->streams.value().Receive({data, dataSize});
if (message.has_value())
session.m_IncomingMessages.push(new std::vector<std::uint8_t>{std::move(message).value()});
increaseWindow(conn, streamId, dataSize);
return 0;
}
void ClientQuicInit(CNetClientSession& session, const AddressStorage& remote, const AddressStorage& local)
{
const ngtcp2_path path{
.local{
.addr{const_cast<ngtcp2_sockaddr*>(&local.address.sa)},
.addrlen{local.length},
},
.remote{
.addr{const_cast<ngtcp2_sockaddr*>(&remote.address.sa)},
.addrlen{remote.length},
}
};
constexpr ngtcp2_callbacks callbacks{
.client_initial{&ngtcp2_crypto_client_initial_cb},
.recv_crypto_data{&ngtcp2_crypto_recv_crypto_data_cb},
.encrypt{&ngtcp2_crypto_encrypt_cb},
.decrypt{&ngtcp2_crypto_decrypt_cb},
.hp_mask{&ngtcp2_crypto_hp_mask_cb},
.recv_stream_data{&OnStreamDataReceive},
.stream_open{&OpenStream},
.recv_retry{&ngtcp2_crypto_recv_retry_cb},
.rand{&OnRandomRequest},
.get_new_connection_id{&OnNewConnectionIdRequest},
.update_key{&ngtcp2_crypto_update_key_cb},
.delete_crypto_aead_ctx{&ngtcp2_crypto_delete_crypto_aead_ctx_cb},
.delete_crypto_cipher_ctx{&ngtcp2_crypto_delete_crypto_cipher_ctx_cb},
.get_path_challenge_data{&ngtcp2_crypto_get_path_challenge_data_cb},
.version_negotiation{&ngtcp2_crypto_version_negotiation_cb}
};
ngtcp2_cid dcid;
dcid.datalen = NGTCP2_MIN_INITIAL_DCIDLEN;
if (gnutls_rnd(GNUTLS_RND_RANDOM, dcid.data, dcid.datalen))
throw std::runtime_error{"gnutls_rnd failed"};
ngtcp2_cid scid;
scid.datalen = 8;
if (gnutls_rnd(GNUTLS_RND_RANDOM, scid.data, scid.datalen))
throw std::runtime_error{"gnutls_rnd failed"};
ngtcp2_settings settings;
ngtcp2_settings_default(&settings);
settings.initial_ts = timestamp();
ngtcp2_transport_params params;
ngtcp2_transport_params_default(&params);
params.initial_max_stream_data_bidi_remote = 128 * KiB;
params.initial_max_data = 1 * MiB;
params.initial_max_streams_bidi = 1;
params.max_udp_payload_size = MAX_UDP_PAYLOAD_SIZE;
params.grease_quic_bit = 1;
ngtcp2_conn* tempConn;
if (const int rv{ngtcp2_conn_client_new(&tempConn, &dcid, &scid, &path,
NGTCP2_PROTO_VER_V1, &callbacks, &settings, &params, nullptr, &session)})
{
throw std::runtime_error{fmt::format("ngtcp2_conn_client_new: {}", ngtcp2_strerror(rv))};
}
session.m_Quic->quicConnection.reset(tempConn);
ngtcp2_conn_set_tls_native_handle(session.m_Quic->quicConnection.get(),
session.m_Quic->session.get());
}
void ClientRead(CNetClientSession::Quic* c) {
std::array<std::uint8_t, MAX_UDP_PAYLOAD_SIZE> buf;
struct sockaddr_storage addr;
iovec iov{
.iov_base = buf.data(),
.iov_len = buf.size(),
};
msghdr msg{};
msg.msg_name = &addr;
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
ngtcp2_pkt_info pi{};
while (true)
{
msg.msg_namelen = sizeof(addr);
const ssize_t nread{recvmsg(c->fd, &msg, MSG_DONTWAIT)};
if (nread == -1)
{
if (errno != EAGAIN && errno != EWOULDBLOCK)
LOGERROR("recvmsg: %s", strerror(errno));
break;
}
ngtcp2_path path{
.local{
.addr{&c->localAddress.address.sa},
.addrlen{c->localAddress.length}
},
.remote{
.addr{static_cast<ngtcp2_sockaddr*>(msg.msg_name)},
.addrlen{msg.msg_namelen}
}
};
const int rv{ngtcp2_conn_read_pkt(c->quicConnection.get(), &path, &pi, buf.data(),
static_cast<std::size_t>(nread), timestamp())};
if (rv != 0)
throw std::runtime_error{fmt::format("ngtcp2_conn_read_pkt: {}", ngtcp2_strerror(rv))};
}
}
void ClientSendDatagram(CNetClientSession::Quic* c, const std::span<const std::uint8_t> data)
{
iovec iov{
.iov_base = const_cast<std::uint8_t*>(data.data()),
.iov_len = data.size(),
};
msghdr msg{};
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
ssize_t nwrite;
do
{
nwrite = sendmsg(c->fd, &msg, 0);
} while (nwrite == -1 && errno == EINTR);
if (nwrite == -1)
throw std::runtime_error{fmt::format("sendmsg: {}", strerror(errno))};
}
void ClientWriteStreams(CNetClientSession::Quic* c)
{
const ngtcp2_tstamp ts{timestamp()};
ngtcp2_pkt_info pi;
std::array<uint8_t, MAX_UDP_PAYLOAD_SIZE> buffer;
ngtcp2_path_storage ps;
ngtcp2_path_storage_zero(&ps);
std::uint32_t flags{NGTCP2_WRITE_STREAM_FLAG_MORE};
while (true)
{
ngtcp2_vec datav;
std::int64_t streamId;
const auto bytesToSend = c->streams.has_value() ? c->streams.value().PeekData() : std::nullopt;
if (c->streams.has_value() && bytesToSend.has_value())
{
datav.base = const_cast<uint8_t*>(bytesToSend->data());
datav.len = bytesToSend->size();
streamId = c->streams.value().m_Id;
}
else
{
datav.base = nullptr;
datav.len = 0;
streamId = -1;
if (c->streams.has_value())
flags &= ~NGTCP2_WRITE_STREAM_FLAG_MORE;
}
ngtcp2_ssize wdatalen;
const ngtcp2_ssize nwrite = ngtcp2_conn_writev_stream(c->quicConnection.get(), &ps.path, &pi,
buffer.data(), buffer.size(), &wdatalen, flags, streamId, &datav, 1, ts);
if (nwrite < 0)
{
if (nwrite == NGTCP2_ERR_STREAM_DATA_BLOCKED)
{
LOGWARNING("blocked");
break;
}
if (nwrite != NGTCP2_ERR_WRITE_MORE)
{
throw std::runtime_error{fmt::format("ngtcp2_conn_writev_stream: {}",
ngtcp2_strerror(static_cast<int>(nwrite)))};
}
if (c->streams.has_value() && wdatalen > 0)
c->streams.value().MarkSent(wdatalen);
continue;
}
if (nwrite == 0)
break;
if (c->streams.has_value() && wdatalen > 0)
c->streams.value().MarkSent(wdatalen);
ClientSendDatagram(c, {buffer.data(), static_cast<std::size_t>(nwrite)});
}
ngtcp2_conn_update_pkt_tx_time(c->quicConnection.get(), timestamp());
}
void ClientHandleExpiry(CNetClientSession::Quic* c)
{
if (const int rv = ngtcp2_conn_handle_expiry(c->quicConnection.get(), timestamp()))
throw std::runtime_error{fmt::format("ngtcp2_conn_handle_expiry: {}", ngtcp2_strerror(rv))};
}
ngtcp2_conn *GetConnection(ngtcp2_crypto_conn_ref* conn_ref)
{
CNetClientSession::Quic* c{static_cast<CNetClientSession::Quic*>(conn_ref->user_data)};
return c->quicConnection.get();
}
void ClientInit(CNetClientSession& session, const char* host, const std::uint16_t port)
{
*session.m_Quic = CNetClientSession::Quic{};
const auto [descriptor, remoteAddress] = CreateSocket(host, port);
session.m_Quic->fd = descriptor;
session.m_Quic->localAddress = ConnectSocket(session.m_Quic->fd, remoteAddress);
ClientGnutlsInit(session.m_Quic.get());
ClientQuicInit(session, remoteAddress, session.m_Quic->localAddress);
session.m_Quic->connectionReference.get_conn = &GetConnection;
session.m_Quic->connectionReference.user_data = session.m_Quic.get();
}
} // anonymous namespace
CNetClientSession::CNetClientSession(CNetClient& client) : CNetClientSession::CNetClientSession(CNetClient& client) :
m_Client(client), m_FileTransferer(*this) m_Client(client), m_FileTransferer(*this),
m_Quic(std::make_unique<Quic>())
{ {
} }
CNetClientSession::~CNetClientSession() CNetClientSession::~CNetClientSession()
{ {
ENSURE(!m_LoopRunning); ENSURE(!m_LoopRunning);
constexpr ngtcp2_ccerr reason{
.type{NGTCP2_CCERR_TYPE_TRANSPORT},
.error_code{0}
};
std::array<std::uint8_t, MAX_UDP_PAYLOAD_SIZE> buffer;
const ngtcp2_ssize amount{ngtcp2_conn_write_connection_close(m_Quic->quicConnection.get(), nullptr, nullptr, buffer.data(),
buffer.size(), &reason, timestamp())};
if (amount <= 0)
{
LOGERROR("closing connection %s", ngtcp2_strerror(amount));
}
ClientSendDatagram(m_Quic.get(), {buffer.data(), static_cast<std::size_t>(amount)});
} }
bool CNetClientSession::Connect(const CStr& server, const u16 port, ENetHost* enetClient) bool CNetClientSession::Connect(const CStr& server, const u16 port)
{ {
ENSURE(!m_LoopRunning); ENSURE(!m_LoopRunning);
ENSURE(!m_Host);
ENSURE(!m_Server);
// Create ENet host if necessary. ClientInit(*this, server.c_str(), port);
m_Host.reset(enetClient != nullptr ? enetClient : PS::Enet::CreateHost(nullptr, 1, CHANNEL_COUNT)); ClientWriteStreams(m_Quic.get());
if (!m_Host) m_Stats = std::make_unique<CNetStatsTable>(m_Quic->quicConnection.get());
return false;
// Bind to specified host
ENetAddress addr;
addr.port = port;
if (enet_address_set_host(&addr, server.c_str()) < 0)
return false;
// Initiate connection to server
m_Server.reset(enet_host_connect(m_Host.get(), &addr, CHANNEL_COUNT, 0));
if (!m_Server)
return false;
m_Stats = std::make_unique<CNetStatsTable>(*m_Server);
if (CProfileViewer::IsInitialised()) if (CProfileViewer::IsInitialised())
g_ProfileViewer.AddRootTable(m_Stats.get()); g_ProfileViewer.AddRootTable(m_Stats.get());
@ -84,14 +446,24 @@ void CNetClientSession::RunNetLoop(CNetClientSession* session)
while (!session->m_ShouldShutdown) while (!session->m_ShouldShutdown)
{ {
ENSURE(session->m_Host && session->m_Server); // ENSURE(session->m_Host && session->m_Server);
session->m_FileTransferer.Poll(); session->m_FileTransferer.Poll();
session->Poll(); try {
session->Poll();
}
catch (std::runtime_error&)
{
// Report immediately.
LOGMESSAGE("Net client: Disconnected");
session->m_Connected = false;
session->m_IncomingMessages.push(Disconnect{});
return;
}
session->Flush(); session->Flush();
session->m_LastReceivedTime = enet_time_get() - session->m_Server->lastReceiveTime; // session->m_LastReceivedTime = timestamp() - session->m_Server->lastReceiveTime;
session->m_MeanRTT = session->m_Server->roundTripTime; // session->m_MeanRTT = session->m_Server->roundTripTime;
} }
session->m_LoopRunning = false; session->m_LoopRunning = false;
@ -107,96 +479,77 @@ void CNetClientSession::Shutdown()
void CNetClientSession::Poll() void CNetClientSession::Poll()
{ {
ENetEvent event; pollfd pfd{
.fd{m_Quic->fd},
.events{POLLIN}
};
// Use the timeout to make the thread wait and save CPU time. const int ret{poll(&pfd, 1, NETCLIENT_POLL_TIMEOUT)};
if (enet_host_service(m_Host.get(), &event, NETCLIENT_POLL_TIMEOUT) <= 0) if (ret < 0)
{
LOGERROR("Error while waiting for poll: %s", std::strerror(errno));
return; return;
if (event.type == ENET_EVENT_TYPE_CONNECT)
{
ENSURE(event.peer == m_Server.get());
// Report the server address immediately.
char hostname[256] = "(error)";
enet_address_get_host_ip(&event.peer->address, hostname, ARRAY_SIZE(hostname));
LOGMESSAGE("Net client: Connected to %s:%u", hostname, (unsigned int)event.peer->address.port);
m_Connected = true;
m_WasConnected = true;
m_IncomingMessages.push(event);
} }
else if (event.type == ENET_EVENT_TYPE_DISCONNECT) if (ret == 0)
{ {
ENSURE(event.peer == m_Server.get()); ClientHandleExpiry(m_Quic.get());
ClientWriteStreams(m_Quic.get());
// Report immediately. return;
LOGMESSAGE("Net client: Disconnected");
m_Connected = false;
m_IncomingMessages.push(event);
} }
else if (event.type == ENET_EVENT_TYPE_RECEIVE)
m_IncomingMessages.push(event); ClientRead(m_Quic.get());
ClientWriteStreams(m_Quic.get());
} }
void CNetClientSession::Flush() void CNetClientSession::Flush()
{ {
ENetPacket* packet; std::vector<std::uint8_t>* message;
while (m_OutgoingMessages.pop(packet)) while (m_OutgoingMessages.pop(message))
if (enet_peer_send(m_Server.get(), CNetHost::DEFAULT_CHANNEL, packet) < 0) {
{ std::unique_ptr<std::vector<std::uint8_t>> data{message};
// Report the error, but do so silently if we know we are disconnected. if (m_Quic->streams.has_value())
if (m_Connected) m_Quic->streams.value().PushData(std::move(*data));
LOGERROR("NetClient: Failed to send packet to server"); else
else LOGERROR("no stream to send message");
LOGMESSAGE("NetClient: Failed to send packet to server"); }
}
enet_host_flush(m_Host.get());
} }
void CNetClientSession::ProcessPolledMessages() void CNetClientSession::ProcessPolledMessages()
{ {
ENetEvent event; IncommingMessage query{};
while(m_IncomingMessages.pop(event)) while(m_IncomingMessages.pop(query))
{ {
if (event.type == ENET_EVENT_TYPE_CONNECT) std::visit([&]<typename Message>(Message message)
m_Client.HandleConnect();
else if (event.type == ENET_EVENT_TYPE_DISCONNECT)
{ {
// This deletes the session, so we must break; if constexpr (std::same_as<Message, ConnectionEstablished>)
if (event.data == 0 && !m_WasConnected)
m_Client.HandleDisconnect(NDR_CONNECTION_REQUEST_TIMED_OUT);
else
m_Client.HandleDisconnect(event.data);
break;
}
else if (event.type == ENET_EVENT_TYPE_RECEIVE)
{
CNetMessage* msg = CNetMessageFactory::CreateMessage(event.packet->data, event.packet->dataLength, m_Client.GetScriptInterface());
if (msg)
{ {
LOGMESSAGE("Net client: Received message %s of size %lu from server", msg->ToString().c_str(), (unsigned long)msg->GetSerializedLength()); m_Client.HandleConnect();
m_Client.HandleMessage(msg);
} }
// Thread-safe else if constexpr (std::same_as<Message, Disconnect>)
enet_packet_destroy(event.packet); {
} m_Client.HandleDisconnect(NDR_UNKNOWN);
}
else
{
static_assert(std::same_as<Message, std::vector<std::uint8_t>*>);
std::unique_ptr<std::vector<std::uint8_t>> data{message};
CNetMessage* msg = CNetMessageFactory::CreateMessage(*data, m_Client.GetScriptInterface());
if (msg)
{
LOGMESSAGE("Net client: Received message %s of size %lu from server", msg->ToString().c_str(), (unsigned long)msg->GetSerializedLength());
m_Client.HandleMessage(msg);
}
}
}, query);
} }
} }
bool CNetClientSession::SendMessage(const CNetMessage* message) bool CNetClientSession::SendMessage(const CNetMessage* message)
{ {
ENSURE(m_Host && m_Server); // ENSURE(m_Host && m_Server);
// Thread-safe. if (!m_OutgoingMessages.push(new std::vector{CNetHost::CreatePacket(message)}))
ENetPacket* packet = CNetHost::CreatePacket(message);
if (!packet)
return false;
if (!m_OutgoingMessages.push(packet))
{ {
LOGERROR("NetClient: Failed to push message on the outgoing queue."); LOGERROR("NetClient: Failed to push message on the outgoing queue.");
return false; return false;
@ -207,17 +560,11 @@ bool CNetClientSession::SendMessage(const CNetMessage* message)
u32 CNetClientSession::GetLastReceivedTime() const u32 CNetClientSession::GetLastReceivedTime() const
{ {
if (!m_Server)
return 0;
return m_LastReceivedTime; return m_LastReceivedTime;
} }
u32 CNetClientSession::GetMeanRTT() const u32 CNetClientSession::GetMeanRTT() const
{ {
if (!m_Server)
return 0;
return m_MeanRTT; return m_MeanRTT;
} }

View file

@ -19,7 +19,6 @@
#define NETSESSION_H #define NETSESSION_H
#include "lib/code_annotation.h" #include "lib/code_annotation.h"
#include "lib/external_libraries/enet.h"
#include "lib/types.h" #include "lib/types.h"
#include "network/NetFileTransfer.h" #include "network/NetFileTransfer.h"
#include "network/NetHost.h" #include "network/NetHost.h"
@ -32,8 +31,6 @@ class CNetMessage;
class CNetStatsTable; class CNetStatsTable;
class CStr; class CStr;
typedef struct _ENetHost ENetHost;
/** /**
* @file * @file
* Network client/server sessions. * Network client/server sessions.
@ -53,10 +50,11 @@ class CNetClientSession
NONCOPYABLE(CNetClientSession); NONCOPYABLE(CNetClientSession);
public: public:
struct Quic;
CNetClientSession(CNetClient& client); CNetClientSession(CNetClient& client);
~CNetClientSession(); ~CNetClientSession();
bool Connect(const CStr& server, const u16 port, ENetHost* enetClient); bool Connect(const CStr& server, const u16 port);
/** /**
* The client NetSession is threaded to avoid getting timeouts if the main thread hangs. * The client NetSession is threaded to avoid getting timeouts if the main thread hangs.
@ -104,11 +102,14 @@ private:
CNetClient& m_Client; CNetClient& m_Client;
CNetFileTransferer m_FileTransferer; CNetFileTransferer m_FileTransferer;
public:
// Net messages received and waiting for fetching. // Net messages received and waiting for fetching.
boost::lockfree::queue<ENetEvent> m_IncomingMessages{16}; struct ConnectionEstablished{};
struct Disconnect{};
using IncommingMessage = std::variant<ConnectionEstablished, std::vector<std::uint8_t>*, Disconnect>;
boost::lockfree::queue<IncommingMessage> m_IncomingMessages{16};
// Net messages to send on the next flush() call. // Net messages to send on the next flush() call.
boost::lockfree::queue<ENetPacket*> m_OutgoingMessages{16}; boost::lockfree::queue<std::vector<std::uint8_t>*> m_OutgoingMessages{16};
// Last known state. If false, flushing errors are silenced. // Last known state. If false, flushing errors are silenced.
bool m_Connected{false}; bool m_Connected{false};
@ -116,7 +117,7 @@ private:
// Whether this session was ever connected to the server. // Whether this session was ever connected to the server.
bool m_WasConnected{false}; bool m_WasConnected{false};
// Wrapper around enet stats - those are atomic as the code is lock-free. // Wrapper around stats - those are atomic as the code is lock-free.
std::atomic<u32> m_LastReceivedTime{0}; std::atomic<u32> m_LastReceivedTime{0};
std::atomic<u32> m_MeanRTT{0}; std::atomic<u32> m_MeanRTT{0};
@ -124,9 +125,9 @@ private:
std::atomic<bool> m_LoopRunning{false}; std::atomic<bool> m_LoopRunning{false};
std::atomic<bool> m_ShouldShutdown{false}; std::atomic<bool> m_ShouldShutdown{false};
std::unique_ptr<ENetHost, DestroyHost> m_Host;
std::unique_ptr<ENetPeer, DestroyPeer> m_Server;
std::unique_ptr<CNetStatsTable> m_Stats; std::unique_ptr<CNetStatsTable> m_Stats;
const std::unique_ptr<Quic> m_Quic;
}; };
#endif // NETSESSION_H #endif // NETSESSION_H

View file

@ -63,7 +63,7 @@ void CNetClientTurnManager::PostCommand(JS::HandleValue data)
// TODO: we should do this when the server stops sending our commands back to us // TODO: we should do this when the server stops sending our commands back to us
} }
void CNetClientTurnManager::NotifyFinishedOwnCommands(u32 turn) void CNetClientTurnManager::NotifyFinishedOwnCommands(turn_id_t turn)
{ {
NETCLIENTTURN_LOG("NotifyFinishedOwnCommands(%d)\n", turn); NETCLIENTTURN_LOG("NotifyFinishedOwnCommands(%d)\n", turn);
@ -79,7 +79,7 @@ void CNetClientTurnManager::NotifyFinishedOwnCommands(u32 turn)
m_NetClient.SendMessage(&msg); m_NetClient.SendMessage(&msg);
} }
void CNetClientTurnManager::NotifyFinishedUpdate(u32 turn, const UpdateCallback&) void CNetClientTurnManager::NotifyFinishedUpdate(turn_id_t turn, const UpdateCallback&)
{ {
bool quick = !TurnNeedsFullHash(turn); bool quick = !TurnNeedsFullHash(turn);
std::string hash; std::string hash;
@ -110,10 +110,10 @@ void CNetClientTurnManager::OnDestroyConnection()
void CNetClientTurnManager::OnSimulationMessage(CSimulationMessage* msg) void CNetClientTurnManager::OnSimulationMessage(CSimulationMessage* msg)
{ {
// Command received from the server - store it for later execution // Command received from the server - store it for later execution
AddCommand(msg->m_Client, msg->m_Player, msg->m_Data, msg->m_Turn); AddCommand(msg->m_Client, msg->m_Player, msg->m_Data, static_cast<turn_id_t>(msg->m_Turn));
} }
void CNetClientTurnManager::OnSyncError(u32 turn, const CStr& expectedHash, const std::vector<CSyncErrorMessage::S_m_PlayerNames>& playerNames) void CNetClientTurnManager::OnSyncError(turn_id_t turn, const CStr& expectedHash, const std::vector<CSyncErrorMessage::S_m_PlayerNames>& playerNames)
{ {
CStr expectedHashHex(Hexify(expectedHash)); CStr expectedHashHex(Hexify(expectedHash));
NETCLIENTTURN_LOG("OnSyncError(%d, %hs)\n", turn, expectedHashHex.c_str()); NETCLIENTTURN_LOG("OnSyncError(%d, %hs)\n", turn, expectedHashHex.c_str());

View file

@ -49,12 +49,12 @@ public:
*/ */
void OnDestroyConnection(); void OnDestroyConnection();
void OnSyncError(u32 turn, const CStr& expectedHash, const std::vector<CSyncErrorMessage::S_m_PlayerNames>& playerNames); void OnSyncError(turn_id_t turn, const CStr& expectedHash, const std::vector<CSyncErrorMessage::S_m_PlayerNames>& playerNames);
private: private:
void NotifyFinishedOwnCommands(u32 turn) override; void NotifyFinishedOwnCommands(turn_id_t turn) override;
void NotifyFinishedUpdate(u32 turn, const UpdateCallback& sendEventToAll) override; void NotifyFinishedUpdate(turn_id_t turn, const UpdateCallback& sendEventToAll) override;
CNetClient& m_NetClient; CNetClient& m_NetClient;
}; };

View file

@ -1,4 +1,4 @@
/* Copyright (C) 2025 Wildfire Games. /* Copyright (C) 2026 Wildfire Games.
* This file is part of 0 A.D. * This file is part of 0 A.D.
* *
* 0 A.D. is free software: you can redistribute it and/or modify * 0 A.D. is free software: you can redistribute it and/or modify
@ -25,32 +25,10 @@
#include "ps/CLogger.h" #include "ps/CLogger.h"
#include <cstddef> #include <cstddef>
#include <numeric>
#include <vector> #include <vector>
bool CNetHost::SendMessage(const CNetMessage* message, ENetPeer* peer, const char* peerName) std::vector<std::uint8_t> CNetHost::CreatePacket(const CNetMessage* message)
{
ENetPacket* packet = CreatePacket(message);
if (!packet)
return false;
LOGMESSAGE("Net: Sending message %s of size %lu to %s", message->ToString().c_str(), (unsigned long)packet->dataLength, peerName);
// Let ENet send the message to peer
if (enet_peer_send(peer, DEFAULT_CHANNEL, packet) < 0)
{
LOGERROR("Net: Failed to send packet to peer");
return false;
}
// Don't call enet_host_flush now - let it queue up all the packets
// and send them during the next frame
//
// TODO: we should flush explicitly at some appropriate point before the next frame
return true;
}
ENetPacket* CNetHost::CreatePacket(const CNetMessage* message)
{ {
size_t size = message->GetSerializedLength(); size_t size = message->GetSerializedLength();
@ -63,12 +41,7 @@ ENetPacket* CNetHost::CreatePacket(const CNetMessage* message)
// Save message to internal buffer // Save message to internal buffer
message->Serialize(&buffer[0]); message->Serialize(&buffer[0]);
// Create a reliable packet return buffer;
ENetPacket* packet = enet_packet_create(&buffer[0], size, ENET_PACKET_FLAG_RELIABLE);
if (!packet)
LOGERROR("Net: Failed to construct packet");
return packet;
} }
void CNetHost::Initialize() void CNetHost::Initialize()
@ -81,3 +54,81 @@ void CNetHost::Deinitialize()
{ {
enet_deinitialize(); enet_deinitialize();
} }
Stream::Stream(const std::int64_t streamId):
m_Id{streamId}
{}
void Stream::PushData(std::vector<std::uint8_t> data)
{
m_SendBuffer.push_back(std::move(data));
}
void Stream::PushMessage(const CNetMessage* message)
{
m_SendBuffer.push_back(CNetHost::CreatePacket(message));
}
std::optional<std::span<const std::uint8_t>> Stream::PeekData()
{
const std::size_t startOffset{m_SentOffset - m_AckedOffset};
std::size_t offset{0};
for (std::vector<std::uint8_t>& bytes : m_SendBuffer)
{
if (startOffset - offset < bytes.size())
{
const std::size_t temp{startOffset - offset};
return std::span{bytes.data() + temp, bytes.size() - temp};
}
offset += bytes.size();
}
return std::nullopt;
}
void Stream::MarkSent(const std::size_t offset)
{
m_SentOffset += offset;
}
void Stream::MarkAcknowledged(const std::size_t offset)
{
while (!m_SendBuffer.empty())
{
std::vector<uint8_t>& head{m_SendBuffer.front()};
if (m_AckedOffset + head.size() > offset)
break;
m_AckedOffset += head.size();
m_SendBuffer.pop_front();
}
}
std::optional<std::vector<std::uint8_t>> Stream::Receive(const std::span<const std::uint8_t> data)
{
m_ReceiveBuffer.emplace_back(data.begin(), data.end());
const std::size_t bufferSize{std::transform_reduce(m_ReceiveBuffer.begin(), m_ReceiveBuffer.end(),
static_cast<std::size_t>(0), std::plus<>{}, std::mem_fn(&std::vector<std::uint8_t>::size))};
if (bufferSize < 3)
return std::nullopt;
const auto& message = m_ReceiveBuffer.front();
auto bufferIter = message.begin();
std::size_t messageSize;
Deserialize_int_1(bufferIter, std::ignore);
Deserialize_int_2(bufferIter, messageSize);
if (messageSize > bufferSize)
return std::nullopt;
std::vector<std::uint8_t> messageCopy;
while (messageCopy.size() < messageSize)
{
messageCopy.insert(messageCopy.end(), m_ReceiveBuffer.front().begin(),
m_ReceiveBuffer.front().end());
m_ReceiveBuffer.pop_front();
}
return messageCopy;
}

View file

@ -27,7 +27,14 @@
#include "lib/types.h" #include "lib/types.h"
#include "ps/CStr.h" #include "ps/CStr.h"
#include <deque>
#include <map> #include <map>
#include <ngtcp2/ngtcp2.h>
#include <optional>
#include <span>
#include <vector>
#include <gnutls/gnutls.h>
class CNetMessage; class CNetMessage;
@ -35,6 +42,38 @@ typedef struct _ENetPeer ENetPeer;
typedef struct _ENetPacket ENetPacket; typedef struct _ENetPacket ENetPacket;
typedef struct _ENetHost ENetHost; typedef struct _ENetHost ENetHost;
constexpr std::size_t MAX_UDP_PAYLOAD_SIZE{64 * KiB};
struct CredentialsDeleter
{
void operator()(const gnutls_certificate_credentials_t cred) const
{
gnutls_certificate_free_credentials(cred);
}
};
struct SessionDeleter
{
void operator()(const gnutls_session_t p) const
{
gnutls_deinit(p);
}
};
struct ConnectionDeleter
{
void operator()(ngtcp2_conn* p) const
{
ngtcp2_conn_del(p);
}
};
struct AddressStorage
{
ngtcp2_sockaddr_union address;
ngtcp2_socklen length{sizeof(address)};
};
struct PlayerAssignment struct PlayerAssignment
{ {
/** /**
@ -106,20 +145,11 @@ class CNetHost
public: public:
static const int DEFAULT_CHANNEL = 0; static const int DEFAULT_CHANNEL = 0;
/**
* Transmit a message to the given peer.
* @param message message to send
* @param peer peer to send to
* @param peerName name of peer for debug logs
* @return true on success, false on failure
*/
static bool SendMessage(const CNetMessage* message, ENetPeer* peer, const char* peerName);
/** /**
* Construct an ENet packet by serialising the given message. * Construct an ENet packet by serialising the given message.
* @return NULL on failure * @return NULL on failure
*/ */
static ENetPacket* CreatePacket(const CNetMessage* message); static std::vector<std::uint8_t> CreatePacket(const CNetMessage* message);
/** /**
* Initialize ENet. * Initialize ENet.
@ -133,4 +163,25 @@ public:
static void Deinitialize(); static void Deinitialize();
}; };
class Stream
{
public:
Stream(const std::int64_t streamId);
void PushData(std::vector<std::uint8_t> data);
void PushMessage(const CNetMessage* message);
std::optional<std::span<const std::uint8_t>> PeekData();
void MarkSent(const std::size_t offset);
void MarkAcknowledged(const std::size_t offset);
std::optional<std::vector<std::uint8_t>> Receive(const std::span<const std::uint8_t> data);
std::int64_t m_Id;
private:
std::deque<std::vector<std::uint8_t>> m_SendBuffer;
std::deque<std::vector<std::uint8_t>> m_ReceiveBuffer;
/* invariant: m_SentOffset >= m_AckedOffset */
std::size_t m_SentOffset{0};
std::size_t m_AckedOffset{0};
};
#endif // NETHOST_H #endif // NETHOST_H

View file

@ -53,10 +53,7 @@ u8* CNetMessage::Serialize(u8* pBuffer) const
const u8* CNetMessage::Deserialize(const u8* pStart, const u8* pEnd) const u8* CNetMessage::Deserialize(const u8* pStart, const u8* pEnd)
{ {
if (pStart + 3 > pEnd) if (pStart + 3 > pEnd)
{ throw std::invalid_argument{"CNetMessage: Corrupt packet (smaller than header)"};
LOGERROR("CNetMessage: Corrupt packet (smaller than header)");
return NULL;
}
const u8* pBuffer = pStart; const u8* pBuffer = pStart;
@ -67,10 +64,7 @@ const u8* CNetMessage::Deserialize(const u8* pStart, const u8* pEnd)
m_Type = (NetMessageType)type; m_Type = (NetMessageType)type;
if (pStart + size != pEnd) if (pStart + size != pEnd)
{ throw std::invalid_argument{fmt::format("CNetMessage: Corrupt packet (incorrect size) %i %i", size, pEnd - pStart)};
LOGERROR("CNetMessage: Corrupt packet (incorrect size)");
return NULL;
}
return pBuffer; return pBuffer;
} }
@ -88,18 +82,17 @@ CStr CNetMessage::ToString() const
if (GetType() == NMT_INVALID) if (GetType() == NMT_INVALID)
return "MESSAGE_TYPE_NONE { Undefined Message }"; return "MESSAGE_TYPE_NONE { Undefined Message }";
else else
return "Unknown Message " + CStr::FromInt(GetType()); return fmt::format("Unknown Message {}", static_cast<int>(GetType()));
} }
CNetMessage* CNetMessageFactory::CreateMessage(const void* pData, CNetMessage* CNetMessageFactory::CreateMessage(const std::span<const std::uint8_t> data,
size_t dataSize, const Script::Interface& scriptInterface)
const Script::Interface& scriptInterface)
{ {
CNetMessage* pNewMessage = NULL; CNetMessage* pNewMessage = NULL;
CNetMessage header; CNetMessage header;
// Figure out message type // Figure out message type
header.Deserialize((const u8*)pData, (const u8*)pData + dataSize); header.Deserialize(std::to_address(data.begin()), std::to_address(data.end()));
switch (header.GetType()) switch (header.GetType())
{ {
@ -230,7 +223,7 @@ CNetMessage* CNetMessageFactory::CreateMessage(const void* pData,
} }
if (pNewMessage) if (pNewMessage)
pNewMessage->Deserialize((const u8*)pData, (const u8*)pData + dataSize); pNewMessage->Deserialize(std::to_address(data.begin()), std::to_address(data.end()));
return pNewMessage; return pNewMessage;
} }

View file

@ -27,6 +27,7 @@
#include <js/RootingAPI.h> #include <js/RootingAPI.h>
#include <js/TypeDecls.h> #include <js/TypeDecls.h>
#include <js/Value.h> #include <js/Value.h>
#include <span>
namespace Script { class Interface; } namespace Script { class Interface; }
@ -108,12 +109,12 @@ public:
/** /**
* Factory method which creates a message object based on the given data * Factory method which creates a message object based on the given data
* *
* @param pData Data buffer * @param data Data buffer
* @param dataSize Size of data buffer
* @param scriptInterface Script instance to use when constructing scripted messages * @param scriptInterface Script instance to use when constructing scripted messages
* @return The new message created * @return The new message created
*/ */
static CNetMessage* CreateMessage(const void* pData, size_t dataSize, const Script::Interface& scriptInterface); static CNetMessage* CreateMessage(const std::span<const std::uint8_t> data,
const Script::Interface& scriptInterface);
}; };
/** /**
@ -139,7 +140,7 @@ public:
u32 m_Client; u32 m_Client;
i32 m_Player; i32 m_Player;
u32 m_Turn; i32 m_Turn;
JS::PersistentRooted<JS::Value> m_Data; JS::PersistentRooted<JS::Value> m_Data;
private: private:
const Script::Interface& m_ScriptInterface; const Script::Interface& m_ScriptInterface;

View file

@ -35,6 +35,7 @@
#include <js/Value.h> #include <js/Value.h>
#include <sstream> #include <sstream>
#include <string> #include <string>
#include "ps/CLogger.h"
namespace Script { class Interface; } namespace Script { class Interface; }
@ -150,7 +151,7 @@ u8* CSimulationMessage::Serialize(u8* pBuffer) const
CBufferBinarySerializer serializer(m_ScriptInterface, pos); CBufferBinarySerializer serializer(m_ScriptInterface, pos);
serializer.NumberU32_Unbounded("client", m_Client); serializer.NumberU32_Unbounded("client", m_Client);
serializer.NumberI32_Unbounded("player", m_Player); serializer.NumberI32_Unbounded("player", m_Player);
serializer.NumberU32_Unbounded("turn", m_Turn); serializer.NumberI32_Unbounded("turn", m_Turn);
serializer.ScriptVal("command", const_cast<JS::PersistentRootedValue*>(&m_Data)); serializer.ScriptVal("command", const_cast<JS::PersistentRootedValue*>(&m_Data));
return serializer.GetBuffer(); return serializer.GetBuffer();
@ -165,7 +166,7 @@ const u8* CSimulationMessage::Deserialize(const u8* pStart, const u8* pEnd)
CStdDeserializer deserializer(m_ScriptInterface, stream); CStdDeserializer deserializer(m_ScriptInterface, stream);
deserializer.NumberU32_Unbounded("client", m_Client); deserializer.NumberU32_Unbounded("client", m_Client);
deserializer.NumberI32_Unbounded("player", m_Player); deserializer.NumberI32_Unbounded("player", m_Player);
deserializer.NumberU32_Unbounded("turn", m_Turn); deserializer.NumberI32_Unbounded("turn", m_Turn);
deserializer.ScriptVal("command", &m_Data); deserializer.ScriptVal("command", &m_Data);
return pEnd; return pEnd;
} }
@ -177,7 +178,7 @@ size_t CSimulationMessage::GetSerializedLength() const
CLengthBinarySerializer serializer(m_ScriptInterface); CLengthBinarySerializer serializer(m_ScriptInterface);
serializer.NumberU32_Unbounded("client", m_Client); serializer.NumberU32_Unbounded("client", m_Client);
serializer.NumberI32_Unbounded("player", m_Player); serializer.NumberI32_Unbounded("player", m_Player);
serializer.NumberU32_Unbounded("turn", m_Turn); serializer.NumberI32_Unbounded("turn", m_Turn);
// TODO: The cast can probably be removed if and when ScriptVal can take a JS::HandleValue instead of // TODO: The cast can probably be removed if and when ScriptVal can take a JS::HandleValue instead of
// a JS::MutableHandleValue (relies on JSAPI change). Also search for other casts like this one in that case. // a JS::MutableHandleValue (relies on JSAPI change). Also search for other casts like this one in that case.

View file

@ -1,4 +1,4 @@
/* Copyright (C) 2025 Wildfire Games. /* Copyright (C) 2026 Wildfire Games.
* This file is part of 0 A.D. * This file is part of 0 A.D.
* *
* 0 A.D. is free software: you can redistribute it and/or modify * 0 A.D. is free software: you can redistribute it and/or modify
@ -27,7 +27,7 @@
#define PS_PROTOCOL_MAGIC 0x5073013f // 'P', 's', 0x01, '?' #define PS_PROTOCOL_MAGIC 0x5073013f // 'P', 's', 0x01, '?'
#define PS_PROTOCOL_MAGIC_RESPONSE 0x50630121 // 'P', 'c', 0x01, '!' #define PS_PROTOCOL_MAGIC_RESPONSE 0x50630121 // 'P', 'c', 0x01, '!'
#define PS_PROTOCOL_VERSION 0x01010019 // Arbitrary protocol #define PS_PROTOCOL_VERSION 0x0101001A // Arbitrary protocol
#define PS_DEFAULT_PORT 0x5073 // 'P', 's' #define PS_DEFAULT_PORT 0x5073 // 'P', 's'
// Set when lobby authentication is required. Used in the SrvHandshakeResponseMessage. // Set when lobby authentication is required. Used in the SrvHandshakeResponseMessage.
@ -231,7 +231,7 @@ START_NMT_CLASS_(ClientPaused, NMT_CLIENT_PAUSED)
END_NMT_CLASS() END_NMT_CLASS()
START_NMT_CLASS_(LoadedGame, NMT_LOADED_GAME) START_NMT_CLASS_(LoadedGame, NMT_LOADED_GAME)
NMT_FIELD_INT(m_CurrentTurn, u32, 4) NMT_FIELD_INT(m_CurrentTurn, i32, 4)
END_NMT_CLASS() END_NMT_CLASS()
START_NMT_CLASS_(GameStart, NMT_GAME_START) START_NMT_CLASS_(GameStart, NMT_GAME_START)
@ -243,17 +243,17 @@ START_NMT_CLASS_(GameSavedStart, NMT_SAVED_GAME_START)
END_NMT_CLASS() END_NMT_CLASS()
START_NMT_CLASS_(EndCommandBatch, NMT_END_COMMAND_BATCH) START_NMT_CLASS_(EndCommandBatch, NMT_END_COMMAND_BATCH)
NMT_FIELD_INT(m_Turn, u32, 4) NMT_FIELD_INT(m_Turn, i32, 4)
NMT_FIELD_INT(m_TurnLength, u32, 2) NMT_FIELD_INT(m_TurnLength, u32, 2)
END_NMT_CLASS() END_NMT_CLASS()
START_NMT_CLASS_(SyncCheck, NMT_SYNC_CHECK) START_NMT_CLASS_(SyncCheck, NMT_SYNC_CHECK)
NMT_FIELD_INT(m_Turn, u32, 4) NMT_FIELD_INT(m_Turn, i32, 4)
NMT_FIELD(CStr, m_Hash) NMT_FIELD(CStr, m_Hash)
END_NMT_CLASS() END_NMT_CLASS()
START_NMT_CLASS_(SyncError, NMT_SYNC_ERROR) START_NMT_CLASS_(SyncError, NMT_SYNC_ERROR)
NMT_FIELD_INT(m_Turn, u32, 4) NMT_FIELD_INT(m_Turn, i32, 4)
NMT_FIELD(CStr, m_HashExpected) NMT_FIELD(CStr, m_HashExpected)
NMT_START_ARRAY(m_PlayerNames) NMT_START_ARRAY(m_PlayerNames)
NMT_FIELD(CStrW, m_Name) NMT_FIELD(CStrW, m_Name)

View file

@ -1,4 +1,4 @@
/* Copyright (C) 2025 Wildfire Games. /* Copyright (C) 2026 Wildfire Games.
* This file is part of 0 A.D. * This file is part of 0 A.D.
* *
* 0 A.D. is free software: you can redistribute it and/or modify * 0 A.D. is free software: you can redistribute it and/or modify
@ -19,10 +19,14 @@
#include "NetProtocol.h" #include "NetProtocol.h"
#include "ps/CLogger.h"
#include "ps/CStr.h" #include "ps/CStr.h"
#include <algorithm> #include <algorithm>
#include <chrono>
#include <cstdint> #include <cstdint>
#include <ngtcp2/ngtcp2.h>
#include <gnutls/crypto.h>
namespace namespace
{ {
@ -59,3 +63,36 @@ std::optional<HandshakeError> CheckHandshake(const CSrvHandshakeMessage& serverM
return {}; return {};
} }
uint64_t timestamp()
{
return std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count();
}
void increaseWindow(ngtcp2_conn* conn, const std::uint64_t streamId, const std::uint64_t size)
{
ngtcp2_conn_extend_max_offset(conn, size);
ngtcp2_conn_extend_max_stream_offset(conn, streamId, size);
}
void OnRandomRequest(std::uint8_t* destination, const std::size_t destinationLength, const ngtcp2_rand_ctx*)
{
const int ret{gnutls_rnd(GNUTLS_RND_RANDOM, destination, destinationLength)};
if (ret < 0)
LOGERROR("gnutls_rnd: %s", gnutls_strerror(ret));
}
int OnNewConnectionIdRequest(ngtcp2_conn*, ngtcp2_cid* cid, std::uint8_t* token, const std::size_t cidlen,
void* /*userData*/)
{
if (gnutls_rnd(GNUTLS_RND_RANDOM, cid->data, cidlen))
return NGTCP2_ERR_CALLBACK_FAILURE;
cid->datalen = cidlen;
if (gnutls_rnd (GNUTLS_RND_RANDOM, token, NGTCP2_STATELESS_RESET_TOKENLEN))
return NGTCP2_ERR_CALLBACK_FAILURE;
return 0;
}

View file

@ -27,11 +27,17 @@
#include <type_traits> #include <type_traits>
#include <vector> #include <vector>
#include <gnutls/gnutls.h>
/** /**
* Report the peer if we didn't receive a packet after this time (milliseconds). * Report the peer if we didn't receive a packet after this time (milliseconds).
*/ */
inline constexpr u32 NETWORK_WARNING_TIMEOUT{2000}; inline constexpr u32 NETWORK_WARNING_TIMEOUT{2000};
inline constexpr const char* TLS_PRIORITY{
"PERFORMANCE:-VERS-DTLS-ALL:-VERS-TLS1.0:-VERS-TLS1.1:-VERS-TLS1.2:-SHA1:-AES-128-CBC:-AES-256-CBC:"
"-SIGN-RSA-SHA1:-SIGN-ECDSA-SHA1:%DISABLE_TLS13_COMPAT_MODE"};
struct HandshakeError struct HandshakeError
{ {
std::string componentType; std::string componentType;
@ -67,4 +73,17 @@ Message CreateHandshake() {
std::optional<HandshakeError> CheckHandshake(const CSrvHandshakeMessage& serverMessage, const CCliHandshakeMessage& clientMessage); std::optional<HandshakeError> CheckHandshake(const CSrvHandshakeMessage& serverMessage, const CCliHandshakeMessage& clientMessage);
uint64_t timestamp();
struct ngtcp2_conn;
void increaseWindow(ngtcp2_conn* conn, const std::uint64_t streamId, const std::uint64_t size);
struct ngtcp2_rand_ctx;
void OnRandomRequest(std::uint8_t* destination, const std::size_t destinationLength,
const ngtcp2_rand_ctx*);
struct ngtcp2_cid;
int OnNewConnectionIdRequest(ngtcp2_conn*, ngtcp2_cid* cid, std::uint8_t *token, const std::size_t cidlen,
void* /*userData*/);
#endif #endif

View file

@ -22,6 +22,7 @@
#include "lib/code_generation.h" #include "lib/code_generation.h"
#include "lib/debug.h" #include "lib/debug.h"
#include "lib/external_libraries/enet.h" #include "lib/external_libraries/enet.h"
#include "lib/hash.h"
#include "lib/secure_crt.h" #include "lib/secure_crt.h"
#include "lib/status.h" #include "lib/status.h"
#include "lib/types.h" #include "lib/types.h"
@ -39,6 +40,8 @@
#include "ps/CLogger.h" #include "ps/CLogger.h"
#include "ps/ConfigDB.h" #include "ps/ConfigDB.h"
#include "ps/GUID.h" #include "ps/GUID.h"
#include "ps/GameSetup/CmdLineArgs.h"
#include "ps/GameSetup/Paths.h"
#include "ps/Hashing.h" #include "ps/Hashing.h"
#include "ps/ProfileViewer.h" #include "ps/ProfileViewer.h"
#include "ps/Profiler2.h" #include "ps/Profiler2.h"
@ -49,13 +52,26 @@
#include "scriptinterface/Context.h" #include "scriptinterface/Context.h"
#include "scriptinterface/Interface.h" #include "scriptinterface/Interface.h"
#include "scriptinterface/Request.h" #include "scriptinterface/Request.h"
#include "simulation2/helpers/SimulationCommand.h"
#include "simulation2/system/TurnManager.h" #include "simulation2/system/TurnManager.h"
#include <algorithm> #include <algorithm>
#include <cstring> #include <cstring>
#include <fmt/format.h>
#include <functional> #include <functional>
#include <gnutls/crypto.h>
#include <gnutls/gnutls.h>
#include <gnutls/x509.h>
#include <iterator> #include <iterator>
#include <memory> #include <memory>
#include <netdb.h>
#include <ngtcp2/ngtcp2.h>
#include <ngtcp2/ngtcp2_crypto.h>
#include <ngtcp2/ngtcp2_crypto_gnutls.h>
#include <numeric>
#include <poll.h>
#include <random>
#include <ranges>
#include <set> #include <set>
#include <sstream> #include <sstream>
#include <string> #include <string>
@ -69,6 +85,10 @@
#include <miniupnpc/upnperrors.h> #include <miniupnpc/upnperrors.h>
#endif #endif
#if FMT_VERSION >= 80000
#include <fmt/xchar.h>
#endif
/** /**
* Number of peers to allocate for the enet host. * Number of peers to allocate for the enet host.
* Limited by ENET_PROTOCOL_MAXIMUM_PEER_ID (4096). * Limited by ENET_PROTOCOL_MAXIMUM_PEER_ID (4096).
@ -97,15 +117,366 @@ constexpr u32 NETWORK_BAD_PING = DEFAULT_TURN_LENGTH * COMMAND_DELAY_MP / 2;
CNetServer* g_NetServer = NULL; CNetServer* g_NetServer = NULL;
static CStr DebugName(CNetServerSession* session) namespace
{ {
if (session == NULL) // static CStr DebugName(CNetServerSession* session)
return "[unknown host]"; // {
if (session->GetGUID().empty()) // if (session == NULL)
return "[unauthed host]"; // return "[unknown host]";
return "[" + session->GetGUID().substr(0, 8) + "...]"; // if (session->GetGUID().empty())
// return "[unauthed host]";
// return "[" + session->GetGUID().substr(0, 8) + "...]";
// }
struct CidHash
{
std::size_t operator()(const ngtcp2_cid& cid) const
{
return std::accumulate(cid.data, cid.data + cid.datalen, static_cast<std::size_t>(0),
[](std::size_t carry, const std::uint8_t byte)
{
hash_combine(carry, byte);
return carry;
});
}
};
struct CidEqual
{
bool operator()(const ngtcp2_cid& a, const ngtcp2_cid& b) const
{
return ngtcp2_cid_eq(&a, &b);
}
};
std::size_t ReceivePackage(const int fd, std::span<std::uint8_t> data, AddressStorage& remoteAddress)
{
iovec iov{
.iov_base{data.data()},
.iov_len{data.size()}
};
msghdr msg{};
msg.msg_name = &remoteAddress.address.sa;
msg.msg_namelen = remoteAddress.length;
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
ssize_t ret;
do
ret = recvmsg(fd, &msg, MSG_DONTWAIT);
while (ret < 0 && errno == EINTR);
if (ret < 0)
throw std::system_error{errno, std::generic_category(), "recvmsg"};
remoteAddress.length = msg.msg_namelen;
return ret;
} }
void GetRandomCid(ngtcp2_cid* cid)
{
std::array<std::uint8_t, NGTCP2_MAX_CIDLEN> buf;
const int ret{gnutls_rnd(GNUTLS_RND_RANDOM, buf.data(), buf.size())};
if (ret < 0)
throw std::runtime_error{fmt::format("gnutls_rnd: {}", gnutls_strerror(ret))};
ngtcp2_cid_init(cid, buf.data(), buf.size());
}
int ResolveAndBind(const char *port, AddressStorage& localAddress)
{
addrinfo hints{};
// IPv4 clients can connect to a IPv6 socket.
hints.ai_family = AF_INET6;
hints.ai_socktype = SOCK_DGRAM;
hints.ai_flags = AI_PASSIVE;
addrinfo* res;
if (getaddrinfo(nullptr, port, &hints, &res))
return -1;
std::unique_ptr<addrinfo, decltype(&freeaddrinfo)> infoList{res, &freeaddrinfo};
int fd;
addrinfo* rp;
for (rp = res; rp; rp = rp->ai_next)
{
fd = socket(AF_INET6, rp->ai_socktype, rp->ai_protocol);
if (fd == -1)
continue;
// Allow IPv4 using the same socket.
const int onlyIPv6{0};
if (setsockopt(fd, IPPROTO_IPV6, IPV6_V6ONLY, &onlyIPv6, sizeof(onlyIPv6)))
continue;
if (bind(fd, rp->ai_addr, rp->ai_addrlen))
{
close(fd);
continue;
}
break;
}
if (!rp)
return -1;
localAddress.address.in6 = reinterpret_cast<ngtcp2_sockaddr_in6&>(*rp->ai_addr);
localAddress.length = rp->ai_addrlen;
return fd;
}
void GenerateKeyfiles(const std::filesystem::path& privatekey, const std::filesystem::path& certificate)
{
constexpr auto binaryOverrideMode = std::ios::binary | std::ios::trunc;
gnutls_x509_privkey_t newKey;
if (const int ret{gnutls_x509_privkey_init(&newKey)})
{
throw std::runtime_error{fmt::format("Error initializing privatkey: {}",
gnutls_strerror(ret))};
}
std::unique_ptr<gnutls_x509_privkey_int, decltype(&gnutls_x509_privkey_deinit)> keyDeleter(
newKey, gnutls_x509_privkey_deinit);
if (const int ret{gnutls_x509_privkey_generate(newKey, GNUTLS_PK_RSA, 3072, 0)})
{
throw std::runtime_error{fmt::format("Error generating privatkey: {}",
gnutls_strerror(ret))};
}
gnutls_datum_t exportedKey;
if (const int ret{gnutls_x509_privkey_export2_pkcs8(newKey, GNUTLS_X509_FMT_DER, nullptr, 0,
&exportedKey)})
{
throw std::runtime_error{fmt::format("Error exporting privatkey: {}",
gnutls_strerror(ret))};
}
const std::unique_ptr<unsigned char, decltype(gnutls_free)> exportedKeyDeleter{exportedKey.data,
gnutls_free};
std::ofstream{privatekey, binaryOverrideMode}.write(
reinterpret_cast<const char*>(exportedKey.data), exportedKey.size);
gnutls_x509_crt_t newCertificate;
if (const int ret{gnutls_x509_crt_init(&newCertificate)})
{
throw std::runtime_error{fmt::format("Error initializing certificate: {}",
gnutls_strerror(ret))};
}
std::unique_ptr<gnutls_x509_crt_int, decltype(&gnutls_x509_crt_deinit)> certificateDeleter(
newCertificate, gnutls_x509_crt_deinit);
std::array<unsigned char, 20> serialNumber;
auto generator = [&, randomDevice = std::random_device{},
distribution = std::uniform_int_distribution<unsigned char>{}]() mutable
{
return distribution(randomDevice);
};
std::ranges::generate(serialNumber, std::ref(generator));
std::get<0>(serialNumber) &= ~(1 << 8);
if (const int ret{gnutls_x509_crt_set_serial(newCertificate, serialNumber.data(),
serialNumber.size())})
{
throw std::runtime_error{fmt::format("Error setting serial number: {}",
gnutls_strerror(ret))};
}
if (const int ret{gnutls_x509_crt_set_activation_time(newCertificate, 0)})
{
throw std::runtime_error{fmt::format("Error setting activation time: {}",
gnutls_strerror(ret))};
}
if (const int ret{gnutls_x509_crt_set_expiration_time(newCertificate, -1)})
{
throw std::runtime_error{fmt::format("Error setting expiration time: {}",
gnutls_strerror(ret))};
}
if (const int ret{gnutls_x509_crt_set_key(newCertificate, newKey)})
{
throw std::runtime_error{fmt::format("Error setting certificate key: {}",
gnutls_strerror(ret))};
}
if (const int ret{gnutls_x509_crt_sign(newCertificate, newCertificate, newKey)})
{
throw std::runtime_error{fmt::format("Error signing certificate: {}",
gnutls_strerror(ret))};
}
gnutls_datum_t exportedCertificate;
if (const int ret{gnutls_x509_crt_export2(newCertificate, GNUTLS_X509_FMT_DER,
&exportedCertificate)})
{
throw std::runtime_error{fmt::format("Error exporting certificate: {}",
gnutls_strerror(ret))};
}
const std::unique_ptr<unsigned char, decltype(gnutls_free)> exportedCertificateDeleter{
exportedCertificate.data, gnutls_free};
std::ofstream{certificate, binaryOverrideMode}.write(
reinterpret_cast<const char*>(exportedCertificate.data), exportedCertificate.size);
}
std::unique_ptr<gnutls_certificate_credentials_st, CredentialsDeleter> CreateTlsServerCredentials()
{
gnutls_certificate_credentials_t temp;
if (const int ret{gnutls_certificate_allocate_credentials(&temp)})
{
throw std::runtime_error{fmt::format("gnutls_certificate_allocate_credentials: {}",
gnutls_strerror(ret))};
}
std::unique_ptr<gnutls_certificate_credentials_st, CredentialsDeleter> cred{temp};
const auto folder = (Paths(g_CmdLineArgs).UserData() / "encryption").fileSystemPath();
std::filesystem::create_directory(folder);
const auto privatekeyPath{folder / "privatekey.der"};
const auto certificatePath{folder / "certificate.der"};
if (!std::filesystem::is_regular_file(privatekeyPath) ||
!std::filesystem::is_regular_file(certificatePath))
{
GenerateKeyfiles(privatekeyPath, certificatePath);
}
if (const int ret{gnutls_certificate_set_x509_key_file(cred.get(), certificatePath.c_str(),
privatekeyPath.c_str(), GNUTLS_X509_FMT_DER)})
{
throw std::runtime_error{fmt::format("gnutls_certificate_set_x509_key_file: {}",
gnutls_strerror(ret))};
}
return cred;
}
ngtcp2_settings InitSettings()
{
ngtcp2_settings settings;
ngtcp2_settings_default(&settings);
settings.initial_ts = timestamp();
return settings;
}
} // anonymous namespace
class CNetServerWorker::Quic
{
public:
explicit Quic(const std::uint16_t port):
m_SocketFd{ResolveAndBind(std::to_string(port).c_str(), m_LocalAddress)}
{}
~Quic()
{
if (m_SocketFd >= 0)
close(m_SocketFd);
}
AddressStorage m_LocalAddress;
int m_SocketFd;
std::unique_ptr<gnutls_certificate_credentials_st, CredentialsDeleter> m_Credentials{
CreateTlsServerCredentials()};
ngtcp2_settings m_Settings{InitSettings()};
void HandleIncoming(CNetServerWorker& server)
{
std::array<std::uint8_t, MAX_UDP_PAYLOAD_SIZE> buf;
while (true)
{
AddressStorage remoteAddress;
std::size_t n_read;
try
{
n_read = ReceivePackage(m_SocketFd, buf, remoteAddress);
}
catch (std::system_error& e)
{
if (e.code().value() == EAGAIN || e.code().value() == EWOULDBLOCK)
return;
throw;
}
ngtcp2_version_cid version;
if (const int ret{ngtcp2_pkt_decode_version_cid(&version, buf.data(), n_read,
NGTCP2_MAX_CIDLEN)})
{
throw std::runtime_error{fmt::format("ngtcp2_pkt_decode_version_cid: {}",
ngtcp2_strerror(ret))};
}
const ngtcp2_addr remote{
.addr{&remoteAddress.address.sa},
.addrlen{remoteAddress.length}
};
/* Find any existing connection by DCID */
ngtcp2_cid tempCid;
ngtcp2_cid_init(&tempCid, version.dcid, version.dcidlen);
const auto connectionIter = std::ranges::find_if(server.m_Sessions,
[&tempCid](const std::vector<ngtcp2_cid> association)
{
return std::ranges::any_of(association, [&tempCid](const ngtcp2_cid& elem)
{
return ngtcp2_cid_eq(&elem, &tempCid);
});
},
[](auto& connection)
{
const std::size_t amount{ngtcp2_conn_get_scid(
connection->m_Connection.m_QuicConnection.get(), nullptr)};
std::vector<ngtcp2_cid> cids(amount);
ngtcp2_conn_get_scid(connection->m_Connection.m_QuicConnection.get(),
cids.data());
return cids;
});
const bool existing{connectionIter != server.m_Sessions.end()};
if (!existing)
{
ngtcp2_pkt_hd header;
if (ngtcp2_accept(&header, buf.data(), n_read))
throw std::invalid_argument{"Failed parsing package header"};
ngtcp2_cid newScid;
GetRandomCid(&newScid);
const ngtcp2_path path{
.local{
.addr{&m_LocalAddress.address.sa},
.addrlen{m_LocalAddress.length}
},
.remote{remote}
};
server.m_Sessions.push_back(std::make_unique<CNetServerSession>(server, m_SocketFd,
m_Settings, m_Credentials.get(), header, newScid, path));
server.SetupSession(server.m_Sessions.back().get());
}
auto& session{existing ? *connectionIter : server.m_Sessions.back()};
try
{
session->m_Connection.Read(remote, {buf.data(), n_read});
}
catch (const std::system_error&)
{
throw;
}
catch (const std::runtime_error&)
{
const auto session = existing ? std::move(*connectionIter) :
std::move(server.m_Sessions.back());
if (existing)
server.m_Sessions.erase(connectionIter);
else
server.m_Sessions.pop_back();
session->Update(static_cast<uint>(NMT_CONNECTION_LOST), nullptr);
}
}
}
};
/* /*
* XXX: We use some non-threadsafe functions from the worker thread. * XXX: We use some non-threadsafe functions from the worker thread.
* See https://gitea.wildfiregames.com/0ad/0ad/issues/654 * See https://gitea.wildfiregames.com/0ad/0ad/issues/654
@ -120,17 +491,17 @@ CNetServerWorker::CNetServerWorker(const bool continueSavedGame, std::uint16_t p
m_Password{std::move(password)} m_Password{std::move(password)}
{ {
// Bind to default host // Bind to default host
ENetAddress addr; // ENetAddress addr;
addr.host = ENET_HOST_ANY; // addr.host = ENET_HOST_ANY;
addr.port = port; // addr.port = port;
// Create ENet server // Create ENet server
m_Host.reset(PS::Enet::CreateHost(&addr, MAX_CLIENTS, CHANNEL_COUNT)); // m_Host.reset(PS::Enet::CreateHost(&addr, MAX_CLIENTS, CHANNEL_COUNT));
if (!m_Host) // if (!m_Host)
{ // {
LOGERROR("Net server: enet_host_create failed"); // LOGERROR("Net server: enet_host_create failed");
throw std::runtime_error{"Failed to start server"}; // throw std::runtime_error{"Failed to start server"};
} // }
m_Stats = std::make_unique<CNetStatsTable>(); m_Stats = std::make_unique<CNetStatsTable>();
if (CProfileViewer::IsInitialised()) if (CProfileViewer::IsInitialised())
@ -140,7 +511,7 @@ CNetServerWorker::CNetServerWorker(const bool continueSavedGame, std::uint16_t p
// Launch the worker thread // Launch the worker thread
m_WorkerThread = std::thread(Threading::HandleExceptions<RunThread>::Wrapper, this, m_WorkerThread = std::thread(Threading::HandleExceptions<RunThread>::Wrapper, this,
std::move(initAttributes)); std::move(initAttributes), port);
#if CONFIG2_MINIUPNPC #if CONFIG2_MINIUPNPC
// Launch the UPnP thread // Launch the UPnP thread
@ -163,10 +534,6 @@ CNetServerWorker::~CNetServerWorker()
if (m_UPnPThread.joinable()) if (m_UPnPThread.joinable())
m_UPnPThread.detach(); m_UPnPThread.detach();
#endif #endif
// Clean up resources
for (const auto& session : m_Sessions)
session->DisconnectNow(NDR_SERVER_SHUTDOWN);
} }
@ -322,21 +689,17 @@ void CNetServerWorker::SetupUPnP(const u16 port)
} }
#endif // CONFIG2_MINIUPNPC #endif // CONFIG2_MINIUPNPC
bool CNetServerWorker::SendMessage(ENetPeer* peer, const CNetMessage* message) bool CNetServerWorker::SendMessage(const CNetMessage* message)
{ {
ENSURE(m_Host); m_Sessions.front()->SendMessage(message);
CNetServerSession* session = static_cast<CNetServerSession*>(peer->data); return true;
return CNetHost::SendMessage(message, peer, DebugName(session).c_str());
} }
bool CNetServerWorker::Multicast(const CNetMessage* message, bool CNetServerWorker::Multicast(const CNetMessage* message,
const std::vector<NetServerSessionState>& targetStates, const std::vector<NetServerSessionState>& targetStates,
const std::optional<std::vector<std::string>>& receivers /* = std::nullopt */) const std::optional<std::vector<std::string>>& receivers /* = std::nullopt */)
{ {
ENSURE(m_Host);
const auto isReceiver = [&](const CNetServerSession& session) const auto isReceiver = [&](const CNetServerSession& session)
{ {
if (!PS::contains(targetStates, if (!PS::contains(targetStates,
@ -361,14 +724,14 @@ bool CNetServerWorker::Multicast(const CNetMessage* message,
return ok; return ok;
} }
void CNetServerWorker::RunThread(CNetServerWorker* data, const std::string& initAttributes) void CNetServerWorker::RunThread(CNetServerWorker* data, const std::string& initAttributes, u16 port)
{ {
debug_SetThreadName("NetServer"); debug_SetThreadName("NetServer");
data->Run(initAttributes); data->Run(initAttributes, port);
} }
void CNetServerWorker::Run(const std::string& initAttributes) void CNetServerWorker::Run(const std::string& initAttributes, u16 port)
{ {
// The script context uses the profiler and therefore the thread must be registered before the context is created // The script context uses the profiler and therefore the thread must be registered before the context is created
g_Profiler2.RegisterCurrentThread("Net server"); g_Profiler2.RegisterCurrentThread("Net server");
@ -386,22 +749,27 @@ void CNetServerWorker::Run(const std::string& initAttributes)
m_InitAttributes = gameAttributesVal; m_InitAttributes = gameAttributesVal;
} }
Quic quic{port};
while (true) while (true)
{ {
if (!RunStep()) if (!RunStep(quic))
break; break;
// Update profiler stats // Update profiler stats
m_Stats->LatchHostState(*m_Host); m_Stats->LatchHostState(m_Sessions);
} }
// Clear roots before deleting their context // Clear roots before deleting their context
m_SavedCommands.clear(); m_SavedCommands.clear();
SAFE_DELETE(m_ScriptInterface); SAFE_DELETE(m_ScriptInterface);
for (const auto& session : m_Sessions)
session->Disconnect(NDR_SERVER_SHUTDOWN);
} }
bool CNetServerWorker::RunStep() bool CNetServerWorker::RunStep(Quic& quic)
{ {
// Check for messages from the game thread. // Check for messages from the game thread.
// (Do as little work as possible while the mutex is held open, // (Do as little work as possible while the mutex is held open,
@ -442,103 +810,40 @@ bool CNetServerWorker::RunStep()
CheckClientConnections(); CheckClientConnections();
// Process network events: pollfd pollFd{
.fd{quic.m_SocketFd},
.events{EPOLLIN | EPOLLOUT}
};
const int ready{poll(&pollFd, 1, 25)};
ENetEvent event; if (ready < 0)
int status = enet_host_service(m_Host.get(), &event, HOST_SERVICE_TIMEOUT); throw std::runtime_error{fmt::format("epoll_wait: {}", std::strerror(errno))};
if (status < 0)
if (ready == 0)
{ {
LOGERROR("CNetServerWorker: enet_host_service failed (%d)", status); for (auto& session : m_Sessions)
// TODO: notify game that the server has shut down
return false;
}
if (status == 0)
{
// Reached timeout with no events - try again
return true;
}
// Process the event:
switch (event.type)
{
case ENET_EVENT_TYPE_CONNECT:
{
// Report the client address
char hostname[256] = "(error)";
enet_address_get_host_ip(&event.peer->address, hostname, ARRAY_SIZE(hostname));
LOGMESSAGE("Net server: Received connection from %s:%u", hostname, (unsigned int)event.peer->address.port);
// Set up a session object for this peer
const std::unique_ptr<CNetServerSession>& session{m_Sessions.emplace_back(
std::make_unique<CNetServerSession>(*this, event.peer))};
SetupSession(session.get());
ENSURE(event.peer->data == NULL);
event.peer->data = session.get();
HandleConnect(session.get());
break;
}
case ENET_EVENT_TYPE_DISCONNECT:
{
// If there is an active session with this peer, then reset and delete it
CNetServerSession* session = static_cast<CNetServerSession*>(event.peer->data);
if (session)
{ {
LOGMESSAGE("Net server: Disconnected %s", DebugName(session).c_str()); ngtcp2_conn *conn = session->m_Connection.m_QuicConnection.get();
const int ret{ngtcp2_conn_handle_expiry(conn, timestamp())};
// Remove the session first, so we won't send player-update messages to it if (ret < 0)
// when updating the FSM
const auto iter = std::ranges::find(m_Sessions, session,
&std::unique_ptr<CNetServerSession>::get);
const std::unique_ptr<CNetServerSession> _ = std::move(*iter);
m_Sessions.erase(iter);
session->Update((uint)NMT_CONNECTION_LOST, NULL);
event.peer->data = NULL;
}
if (m_State == SERVER_STATE_LOADING)
CheckGameLoadStatus(NULL);
break;
}
case ENET_EVENT_TYPE_RECEIVE:
{
// If there is an active session with this peer, then process the message
CNetServerSession* session = static_cast<CNetServerSession*>(event.peer->data);
if (session)
{
// Create message from raw data
CNetMessage* msg = CNetMessageFactory::CreateMessage(event.packet->data, event.packet->dataLength, GetScriptInterface());
if (msg)
{ {
LOGMESSAGE("Net server: Received message %s of size %lu from %s", msg->ToString().c_str(), (unsigned long)msg->GetSerializedLength(), DebugName(session).c_str()); LOGERROR("ngtcp2_conn_handle_expiry: %s", ngtcp2_strerror(ret));
continue;
HandleMessageReceive(msg, session);
delete msg;
} }
session->m_Connection.Write(quic.m_SocketFd);
} }
// Done using the packet
enet_packet_destroy(event.packet);
break;
} }
else
{
if (pollFd.revents & EPOLLIN)
quic.HandleIncoming(*this);
case ENET_EVENT_TYPE_NONE: if (pollFd.revents & EPOLLOUT)
break; {
for (auto& session : m_Sessions)
session->m_Connection.Write(quic.m_SocketFd);
}
} }
return true; return true;
@ -1172,9 +1477,9 @@ bool CNetServerWorker::OnSimulationCommand(CNetServerSession* session, CFsmEvent
server.Multicast(message, { NSS_INGAME }); server.Multicast(message, { NSS_INGAME });
// Save all the received commands // Save all the received commands
if (server.m_SavedCommands.size() < message->m_Turn + 1) if (server.m_SavedCommands.size() < static_cast<size_t>(message->m_Turn) + 1)
server.m_SavedCommands.resize(message->m_Turn + 1); server.m_SavedCommands.resize(static_cast<size_t>(message->m_Turn) + 1);
server.m_SavedCommands[message->m_Turn].push_back(*message); server.m_SavedCommands[static_cast<size_t>(message->m_Turn)].push_back(*message);
// TODO: we shouldn't send the message back to the client that first sent it // TODO: we shouldn't send the message back to the client that first sent it
return true; return true;
@ -1396,14 +1701,14 @@ bool CNetServerWorker::OnJoinSyncingLoadedGame(CNetServerSession* session, CFsmE
CLoadedGameMessage* message = (CLoadedGameMessage*)event->GetParamRef(); CLoadedGameMessage* message = (CLoadedGameMessage*)event->GetParamRef();
u32 turn = message->m_CurrentTurn; turn_id_t turn = message->m_CurrentTurn;
u32 readyTurn = server.m_ServerTurnManager->GetReadyTurn(); turn_id_t readyTurn = server.m_ServerTurnManager->GetReadyTurn();
// Send them all commands received since their saved state, // Send them all commands received since their saved state,
// and turn-ended messages for any turns that have already been processed // and turn-ended messages for any turns that have already been processed
for (size_t i = turn + 1; i < std::max(readyTurn+1, (u32)server.m_SavedCommands.size()); ++i) for (turn_id_t i = turn + 1; i < std::max(readyTurn + 1, static_cast<turn_id_t>(server.m_SavedCommands.size())); ++i)
{ {
if (i < server.m_SavedCommands.size()) if (static_cast<size_t>(i) < server.m_SavedCommands.size())
for (size_t j = 0; j < server.m_SavedCommands[i].size(); ++j) for (size_t j = 0; j < server.m_SavedCommands[i].size(); ++j)
session->SendMessage(&server.m_SavedCommands[i][j]); session->SendMessage(&server.m_SavedCommands[i][j]);
@ -1640,14 +1945,12 @@ CStrW CNetServerWorker::DeduplicatePlayerName(const CStrW& original)
if (unique) if (unique)
return name; return name;
name = original + L" (" + CStrW::FromUInt(id++) + L")"; name = fmt::format(L"{}({})", original, id++);
} }
} }
void CNetServerWorker::SendHolePunchingMessage(const CStr& ipStr, u16 port) void CNetServerWorker::SendHolePunchingMessage(const CStr& /*ipStr*/, u16 /*port*/)
{ {
if (m_Host)
StunClient::SendHolePunchingMessages(*m_Host, ipStr, port);
} }
@ -1665,9 +1968,9 @@ CNetServer::CNetServer(const bool continueSavedGame, std::uint16_t port, const b
// In lobby, we send our public ip and port on request to the players who want to connect. // In lobby, we send our public ip and port on request to the players who want to connect.
// Thus we need to know our public IP and use STUN to get it. // Thus we need to know our public IP and use STUN to get it.
std::lock_guard<std::mutex> lock(m_Worker.m_WorkerMutex); // std::lock_guard<std::mutex> lock(m_Worker.m_WorkerMutex);
if (!m_Worker.m_Host || !StunClient::FindPublicIP(*m_Worker.m_Host, m_PublicIp, m_PublicPort)) // if (!m_Worker.m_Host || !StunClient::FindPublicIP(*m_Worker.m_Host, m_PublicIp, m_PublicPort))
throw std::runtime_error{"Failed to resolve public IP-address."}; // throw std::runtime_error{"Failed to resolve public IP-address."};
} }
bool CNetServer::UseLobbyAuth() const bool CNetServer::UseLobbyAuth() const
@ -1688,9 +1991,7 @@ u16 CNetServer::GetPublicPort() const
u16 CNetServer::GetLocalPort() const u16 CNetServer::GetLocalPort() const
{ {
std::lock_guard<std::mutex> lock(m_Worker.m_WorkerMutex); std::lock_guard<std::mutex> lock(m_Worker.m_WorkerMutex);
if (!m_Worker.m_Host) return 0; // m_Worker.m_Host->address.port;
return 0;
return m_Worker.m_Host->address.port;
} }
bool CNetServer::CheckPasswordAndIncrement(const std::string& username, const std::string& password, const std::string& salt) bool CNetServer::CheckPasswordAndIncrement(const std::string& username, const std::string& password, const std::string& salt)
@ -1737,3 +2038,4 @@ void CNetServer::SendHolePunchingMessage(const CStr& ip, u16 port)
{ {
m_Worker.SendHolePunchingMessage(ip, port); m_Worker.SendHolePunchingMessage(ip, port);
} }

View file

@ -119,7 +119,7 @@ public:
/** /**
* Send a message to the given network peer. * Send a message to the given network peer.
*/ */
bool SendMessage(ENetPeer* peer, const CNetMessage* message); bool SendMessage(const CNetMessage* message);
/** /**
* Disconnects a player from gamesetup or session. * Disconnects a player from gamesetup or session.
@ -173,10 +173,12 @@ private:
*/ */
CStrW DeduplicatePlayerName(const CStrW& original); CStrW DeduplicatePlayerName(const CStrW& original);
public:
/** /**
* Get the script context used for init attributes. * Get the script context used for init attributes.
*/ */
const Script::Interface& GetScriptInterface(); const Script::Interface& GetScriptInterface();
private:
/** /**
* Set the turn length to a fixed value. * Set the turn length to a fixed value.
@ -227,7 +229,9 @@ private:
void ConstructPlayerAssignmentMessage(CPlayerAssignmentMessage& message); void ConstructPlayerAssignmentMessage(CPlayerAssignmentMessage& message);
public:
void HandleMessageReceive(CNetMessage* message, CNetServerSession* session); void HandleMessageReceive(CNetMessage* message, CNetServerSession* session);
public:
/** /**
* Send a network warning if the connection to a client is being lost or has bad latency. * Send a network warning if the connection to a client is being lost or has bad latency.
@ -263,7 +267,8 @@ private:
*/ */
const bool m_LobbyAuth; const bool m_LobbyAuth;
std::unique_ptr<ENetHost, DestroyHost> m_Host; class Quic;
std::vector<std::unique_ptr<CNetServerSession>> m_Sessions; std::vector<std::unique_ptr<CNetServerSession>> m_Sessions;
std::unique_ptr<CNetStatsTable> m_Stats; std::unique_ptr<CNetStatsTable> m_Stats;
@ -330,9 +335,9 @@ private:
std::thread m_UPnPThread; std::thread m_UPnPThread;
#endif #endif
static void RunThread(CNetServerWorker* data, const std::string& initAttributes); static void RunThread(CNetServerWorker* data, const std::string& initAttributes, u16 port);
void Run(const std::string& initAttributes); void Run(const std::string& initAttributes, u16 port);
bool RunStep(); bool RunStep(Quic& quic);
std::thread m_WorkerThread; std::thread m_WorkerThread;
mutable std::mutex m_WorkerMutex; mutable std::mutex m_WorkerMutex;

View file

@ -25,8 +25,312 @@
#include "network/NetServer.h" #include "network/NetServer.h"
#include "ps/CLogger.h" #include "ps/CLogger.h"
CNetServerSession::CNetServerSession(CNetServerWorker& server, ENetPeer* peer) :
m_Server(server), m_FileTransferer(*this), m_Peer(peer) #include <gnutls/crypto.h>
#include <gnutls/gnutls.h>
namespace
{
void SendPacket(const int socketFd, const std::span<const std::uint8_t> data, const ngtcp2_addr remote)
{
iovec iov{
.iov_base{const_cast<std::uint8_t*>(data.data())},
.iov_len{data.size()}
};
msghdr msg{};
msg.msg_name = remote.addr;
msg.msg_namelen = remote.addrlen;
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
ssize_t ret;
do
ret = sendmsg(socketFd, &msg, MSG_DONTWAIT);
while (ret < 0 && errno == EINTR);
if (ret < 0)
throw std::system_error{errno, std::generic_category(), "Error sending message"};
}
ngtcp2_conn* GetConnection(ngtcp2_crypto_conn_ref* connRef)
{
return reinterpret_cast<std::unique_ptr<ngtcp2_conn, ConnectionDeleter>*>(connRef->user_data)->get();
}
int OnReceiveStreamData(ngtcp2_conn* conn, const std::uint32_t /*flags*/, const std::int64_t streamId,
const std::uint64_t /*offset*/, const std::uint8_t* data, const std::size_t datalen, void* userData,
void* /*streamUserData*/)
{
CNetServerSession& session{*static_cast<CNetServerSession*>(userData)};
const auto messageData = session.m_Connection.m_Stream.value().Receive({data, datalen});
if (messageData.has_value())
{
std::unique_ptr<CNetMessage> message{CNetMessageFactory::CreateMessage(messageData.value(),
session.GetServer().GetScriptInterface())};
session.GetServer().HandleMessageReceive(message.get(), &session);
}
increaseWindow(conn, streamId, datalen);
return 0;
}
int OnAcknowledgedStreamData(ngtcp2_conn*, const std::int64_t, const std::uint64_t offset,
const std::uint64_t dataLength, void* userData, void*)
{
Connection& connection{static_cast<CNetServerSession*>(userData)->m_Connection};
Stream& stream{connection.GetStream()};
stream.MarkAcknowledged(offset + dataLength);
return 0;
}
int OnConnect(ngtcp2_conn*, const ngtcp2_encryption_level level, void* userData)
{
if (level != NGTCP2_ENCRYPTION_LEVEL_1RTT)
return 0;
Connection& connection{static_cast<CNetServerSession*>(userData)->m_Connection};
try
{
connection.OpenStream();
}
catch (const std::runtime_error&)
{
return NGTCP2_ERR_CALLBACK_FAILURE;
}
const CSrvHandshakeMessage handshake(CreateHandshake<CSrvHandshakeMessage>());
connection.GetStream().PushMessage(&handshake);
return 0;
}
constexpr ngtcp2_callbacks callbacks{
.recv_client_initial{&ngtcp2_crypto_recv_client_initial_cb},
.recv_crypto_data{&ngtcp2_crypto_recv_crypto_data_cb},
.encrypt{&ngtcp2_crypto_encrypt_cb},
.decrypt{&ngtcp2_crypto_decrypt_cb},
.hp_mask{&ngtcp2_crypto_hp_mask_cb},
.recv_stream_data{&OnReceiveStreamData},
.acked_stream_data_offset{&OnAcknowledgedStreamData},
.recv_retry{&ngtcp2_crypto_recv_retry_cb},
.rand{&OnRandomRequest},
.get_new_connection_id{&OnNewConnectionIdRequest},
.update_key{&ngtcp2_crypto_update_key_cb},
.delete_crypto_aead_ctx{&ngtcp2_crypto_delete_crypto_aead_ctx_cb},
.delete_crypto_cipher_ctx{&ngtcp2_crypto_delete_crypto_cipher_ctx_cb},
.get_path_challenge_data{&ngtcp2_crypto_get_path_challenge_data_cb},
.recv_tx_key{&OnConnect}
};
void WriteToStream(const int socketFd, ngtcp2_conn* conn, Stream* stream, const ngtcp2_addr remote)
{
std::array<std::uint8_t, MAX_UDP_PAYLOAD_SIZE> buf;
ngtcp2_path_storage ps;
ngtcp2_path_storage_zero(&ps);
ngtcp2_pkt_info pi;
const std::uint64_t ts{timestamp()};
std::uint32_t flags{NGTCP2_WRITE_STREAM_FLAG_MORE};
while (true)
{
ngtcp2_vec datav;
std::int64_t stream_id;
if (stream)
{
auto bytesToSend = stream->PeekData();
if (bytesToSend.has_value())
{
datav.base = const_cast<uint8_t*>(bytesToSend->data());
datav.len = bytesToSend->size();
stream_id = stream->m_Id;
}
else
{
/* No stream data to be sent */
datav.base = nullptr;
datav.len = 0;
stream_id = -1;
flags &= ~NGTCP2_WRITE_STREAM_FLAG_MORE;
}
}
else
{
datav.base = NULL;
datav.len = 0;
stream_id = -1;
}
ngtcp2_ssize n_read;
const ngtcp2_ssize n_written{ngtcp2_conn_writev_stream(conn, &ps.path, &pi, buf.data(),
buf.size(), &n_read, flags, stream_id, &datav, 1, ts)};
if (n_written < 0)
{
if (n_written != NGTCP2_ERR_WRITE_MORE)
{
throw std::runtime_error{fmt::format("ngtcp2_conn_writev_stream: {}",
ngtcp2_strerror(static_cast<int>(n_written)))};
}
if (stream && n_read > 0)
stream->MarkSent(n_read);
continue;
}
if (n_written == 0)
break;
if (stream && n_read > 0)
stream->MarkSent(n_read);
try
{
SendPacket(socketFd, {buf.data(), static_cast<std::size_t>(n_written)}, remote);
}
catch (std::system_error& e)
{
if (e.code().value() == EAGAIN || e.code().value() == EWOULDBLOCK)
break;
throw;
}
/* No stream data to be sent */
if (stream && datav.len == 0)
break;
}
ngtcp2_conn_update_pkt_tx_time(conn, timestamp());
}
std::unique_ptr<gnutls_session_int, SessionDeleter> createTlsSession(
const gnutls_certificate_credentials_t cred)
{
gnutls_session_t tempSession;
if (const int ret{gnutls_init(&tempSession, GNUTLS_SERVER | GNUTLS_ENABLE_EARLY_DATA |
GNUTLS_NO_END_OF_EARLY_DATA)})
{
throw std::runtime_error{fmt::format("gnutls_init: {}", gnutls_strerror(ret))};
}
std::unique_ptr<gnutls_session_int, SessionDeleter> session{tempSession};
if (const int ret{gnutls_priority_set_direct(session.get(), TLS_PRIORITY, NULL)})
throw std::runtime_error{fmt::format("gnutls_priority_set_direct: {}", gnutls_strerror(ret))};
if (const int ret{gnutls_credentials_set(session.get(), GNUTLS_CRD_CERTIFICATE, cred)})
throw std::runtime_error{fmt::format("gnutls_credentials_set: {}", gnutls_strerror(ret))};
return session;
}
}
Connection::Connection(CNetServerSession& session, const ngtcp2_settings& settings,
gnutls_certificate_credentials_t credentials, const ngtcp2_pkt_hd& header,
const ngtcp2_cid& newScid, const ngtcp2_path& path):
m_TlsSession{createTlsSession(credentials)},
m_ConnRef{
.get_conn{&GetConnection},
.user_data{&m_QuicConnection}
}
{
ngtcp2_transport_params params;
ngtcp2_transport_params_default(&params);
params.initial_max_stream_data_bidi_local = 128 * KiB;
params.initial_max_data = 1 * MiB;
params.max_udp_payload_size = MAX_UDP_PAYLOAD_SIZE;
ngtcp2_cid_init(&params.original_dcid, header.dcid.data, header.dcid.datalen);
params.original_dcid_present = true;
params.grease_quic_bit = 1;
ngtcp2_conn* tempConn;
if (const int ret = ngtcp2_conn_server_new(&tempConn, &header.scid, &newScid, &path, header.version,
&callbacks, &settings, &params, nullptr, &session))
{
throw std::runtime_error{fmt::format("ngtcp2_conn_server_new: {}",
ngtcp2_strerror (ret))};
}
m_QuicConnection.reset(tempConn);
memcpy(&m_LocalAddress, path.local.addr, path.local.addrlen);
m_LocalAddressLength = path.local.addrlen;
memcpy(&m_RemoteAddress, path.remote.addr, path.remote.addrlen);
m_RemoteAddressLength = path.remote.addrlen;
ngtcp2_crypto_gnutls_configure_server_session(m_TlsSession.get());
ngtcp2_conn_set_tls_native_handle(m_QuicConnection.get(), m_TlsSession.get());
gnutls_session_set_ptr(m_TlsSession.get(), &m_ConnRef);
m_TimerFd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK);
if (m_TimerFd < 0)
throw std::system_error(errno, std::generic_category(), "timerfd_create");
}
void Connection::OpenStream()
{
std::int64_t streamId;
if (ngtcp2_conn_open_bidi_stream(m_QuicConnection.get(), &streamId, nullptr))
throw std::runtime_error{""};
m_Stream.emplace(streamId);
}
Stream& Connection::GetStream()
{
return m_Stream.value();
}
void Connection::Read(const ngtcp2_addr remote, const std::span<std::uint8_t> data)
{
const ngtcp2_path path{
.local{ngtcp2_conn_get_path(m_QuicConnection.get())->local},
.remote{remote}
};
ngtcp2_pkt_info pi{};
if (const int ret{ngtcp2_conn_read_pkt(m_QuicConnection.get(), &path, &pi, data.data(), data.size(),
timestamp())})
{
throw std::runtime_error{"Destroy connection"};
}
}
void Connection::Write(const int socketFd)
{
WriteToStream(socketFd, m_QuicConnection.get(), nullptr,
{&m_RemoteAddress.sa, m_RemoteAddressLength});
if (m_Stream.has_value())
{
WriteToStream(socketFd, m_QuicConnection.get(), &m_Stream.value(),
{&m_RemoteAddress.sa, m_RemoteAddressLength});
}
const ngtcp2_tstamp expiry{ngtcp2_conn_get_expiry(m_QuicConnection.get())};
const ngtcp2_tstamp now{timestamp()};
itimerspec it{};
if (const int ret{timerfd_settime(m_TimerFd, 0, &it, nullptr)})
throw std::system_error{errno, std::generic_category(), "timerfd_settime"};
if (expiry < now)
{
it.it_value.tv_sec = 0;
it.it_value.tv_nsec = 1;
}
else
{
it.it_value.tv_sec = (expiry - now) / NGTCP2_SECONDS;
it.it_value.tv_nsec = ((expiry - now) % NGTCP2_SECONDS) / NGTCP2_NANOSECONDS;
}
if (const int ret{timerfd_settime(m_TimerFd, 0, &it, nullptr)})
throw std::system_error{errno, std::generic_category(), "timerfd_settime"};
return;
}
CNetServerSession::CNetServerSession(CNetServerWorker& server, const int socketFd,
const ngtcp2_settings& settings, gnutls_certificate_credentials_t credentials,
const ngtcp2_pkt_hd& header, const ngtcp2_cid& newScid, const ngtcp2_path& path) :
m_Server(server),
m_Connection{*this, settings, credentials, header, newScid, path},
m_FileTransferer(*this),
m_SocketFd{socketFd}
{ {
} }
@ -58,18 +362,30 @@ void CNetServerSession::Disconnect(NetDisconnectReason reason)
Update((uint)NMT_CONNECTION_LOST, NULL); Update((uint)NMT_CONNECTION_LOST, NULL);
enet_peer_disconnect(m_Peer, static_cast<enet_uint32>(reason)); const ngtcp2_ccerr quicReason{
} .type{NGTCP2_CCERR_TYPE_APPLICATION},
.error_code{reason}
};
void CNetServerSession::DisconnectNow(NetDisconnectReason reason) ngtcp2_sockaddr_union local;
{ ngtcp2_sockaddr_union remote;
if (reason == NDR_UNKNOWN) ngtcp2_path path{
LOGWARNING("Disconnecting client without communicating the disconnect reason!"); .local{.addr{&local.sa}},
.remote{.addr{&remote.sa}}
};
std::array<std::uint8_t, MAX_UDP_PAYLOAD_SIZE> buffer;
const ngtcp2_ssize amount{ngtcp2_conn_write_connection_close(m_Connection.m_QuicConnection.get(), &path, nullptr, buffer.data(),
buffer.size(), &quicReason, timestamp())};
if (amount <= 0)
LOGERROR("closing connection %s", ngtcp2_strerror(static_cast<int>(amount)));
enet_peer_disconnect_now(m_Peer, static_cast<enet_uint32>(reason)); SendPacket(m_SocketFd, {buffer.data(), static_cast<std::size_t>(amount)}, path.remote);
} }
bool CNetServerSession::SendMessage(const CNetMessage* message) bool CNetServerSession::SendMessage(const CNetMessage* message)
{ {
return m_Server.SendMessage(m_Peer, message); m_Connection.m_Stream.value().PushMessage(message);
return true;
} }

View file

@ -23,10 +23,51 @@
#include "network/NetHost.h" #include "network/NetHost.h"
#include "ps/CStr.h" #include "ps/CStr.h"
#include <deque>
#include <ngtcp2/ngtcp2.h>
#include <ngtcp2/ngtcp2_crypto.h>
#include <ngtcp2/ngtcp2_crypto_gnutls.h>
#include <optional>
#include <span>
#include <sys/timerfd.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <sys/timerfd.h>
#include "network/NetProtocol.h"
#include "ps/CLogger.h"
class CNetServerWorker; class CNetServerWorker;
typedef struct _ENetPeer ENetPeer; typedef struct _ENetPeer ENetPeer;
class CNetServerSession;
class Connection
{
public:
Connection(CNetServerSession& session, const ngtcp2_settings& settings,
gnutls_certificate_credentials_t credentials, const ngtcp2_pkt_hd& header,
const ngtcp2_cid& scid, const ngtcp2_path& path);
void OpenStream();
Stream& GetStream();
void Read(const ngtcp2_addr remote, const std::span<std::uint8_t> data);
void Write(const int socketFd);
std::unique_ptr<gnutls_session_int, SessionDeleter> m_TlsSession;
std::unique_ptr<ngtcp2_conn, ConnectionDeleter> m_QuicConnection;
int m_TimerFd{-1};
public:
ngtcp2_sockaddr_union m_LocalAddress;
ngtcp2_socklen m_LocalAddressLength;
ngtcp2_sockaddr_union m_RemoteAddress;
ngtcp2_socklen m_RemoteAddressLength;
std::optional<Stream> m_Stream;
ngtcp2_crypto_conn_ref m_ConnRef;
};
/** /**
* The server's end of a network session. * The server's end of a network session.
* Represents an abstraction of the state of the client, storing all the per-client data * Represents an abstraction of the state of the client, storing all the per-client data
@ -40,7 +81,9 @@ class CNetServerSession : public CFsm<CNetServerSession, CNetMessage*>
NONCOPYABLE(CNetServerSession); NONCOPYABLE(CNetServerSession);
public: public:
CNetServerSession(CNetServerWorker& server, ENetPeer* peer); CNetServerSession(CNetServerWorker& server, const int socketFd, const ngtcp2_settings& settings,
gnutls_certificate_credentials_t credentials, const ngtcp2_pkt_hd& header,
const ngtcp2_cid& newScid, const ngtcp2_path& path);
CNetServerWorker& GetServer() { return m_Server; } CNetServerWorker& GetServer() { return m_Server; }
@ -73,13 +116,6 @@ public:
*/ */
void Disconnect(NetDisconnectReason reason); void Disconnect(NetDisconnectReason reason);
/**
* Sends an unreliable disconnection notification to the client.
* The server will not receive any disconnection notification.
* The server will not receive any further messages sent via this session.
*/
void DisconnectNow(NetDisconnectReason reason);
/** /**
* Send a message to the client. * Send a message to the client.
*/ */
@ -89,7 +125,10 @@ public:
private: private:
CNetServerWorker& m_Server; CNetServerWorker& m_Server;
public:
Connection m_Connection;
private:
CNetFileTransferer m_FileTransferer; CNetFileTransferer m_FileTransferer;
ENetPeer* m_Peer; ENetPeer* m_Peer;
@ -98,6 +137,7 @@ private:
CStrW m_UserName; CStrW m_UserName;
u32 m_HostID{0}; u32 m_HostID{0};
CStr m_Password; CStr m_Password;
int m_SocketFd;
}; };
#endif // NET_SERVER_SESSION_H #endif // NET_SERVER_SESSION_H

View file

@ -27,6 +27,7 @@
#include "network/NetServerSession.h" #include "network/NetServerSession.h"
#include "ps/CLogger.h" #include "ps/CLogger.h"
#include "ps/ConfigDB.h" #include "ps/ConfigDB.h"
#include "simulation2/helpers/SimulationCommand.h"
#include "simulation2/system/TurnManager.h" #include "simulation2/system/TurnManager.h"
#include <limits> #include <limits>
@ -50,7 +51,7 @@ CNetServerTurnManager::CNetServerTurnManager(CNetServerWorker& server)
m_SavedTurnLengths.push_back(m_TurnLength); m_SavedTurnLengths.push_back(m_TurnLength);
} }
void CNetServerTurnManager::NotifyFinishedClientCommands(CNetServerSession& session, u32 turn) void CNetServerTurnManager::NotifyFinishedClientCommands(CNetServerSession& session, turn_id_t turn)
{ {
int client = session.GetHostID(); int client = session.GetHostID();
@ -105,11 +106,11 @@ void CNetServerTurnManager::CheckClientsReady()
msg.m_Turn = m_ReadyTurn; msg.m_Turn = m_ReadyTurn;
m_NetServer.Multicast(&msg, { NSS_INGAME }); m_NetServer.Multicast(&msg, { NSS_INGAME });
ENSURE(m_SavedTurnLengths.size() == m_ReadyTurn); ENSURE(std::cmp_equal(m_SavedTurnLengths.size(), m_ReadyTurn));
m_SavedTurnLengths.push_back(m_TurnLength); m_SavedTurnLengths.push_back(m_TurnLength);
} }
void CNetServerTurnManager::NotifyFinishedClientUpdate(CNetServerSession& session, u32 turn, const CStr& hash) void CNetServerTurnManager::NotifyFinishedClientUpdate(CNetServerSession& session, turn_id_t turn, const CStr& hash)
{ {
int client = session.GetHostID(); int client = session.GetHostID();
@ -138,13 +139,13 @@ void CNetServerTurnManager::NotifyFinishedClientUpdate(CNetServerSession& sessio
m_ClientStateHashes[turn][client] = hash; m_ClientStateHashes[turn][client] = hash;
// Find the newest turn which we know all clients have simulated // Find the newest turn which we know all clients have simulated
u32 newest = std::numeric_limits<u32>::max(); turn_id_t newest = std::numeric_limits<turn_id_t>::max();
for (const std::pair<const int, Client>& clientData : m_ClientsData) for (const std::pair<const int, Client>& clientData : m_ClientsData)
if (clientData.second.simulatedTurn < newest) if (clientData.second.simulatedTurn < newest)
newest = clientData.second.simulatedTurn; newest = clientData.second.simulatedTurn;
// For every set of state hashes that all clients have simulated, check for OOS // For every set of state hashes that all clients have simulated, check for OOS
for (const std::pair<const u32, std::map<int, std::string>>& clientStateHash : m_ClientStateHashes) for (const std::pair<const turn_id_t, std::map<int, std::string>>& clientStateHash : m_ClientStateHashes)
{ {
if (clientStateHash.first > newest) if (clientStateHash.first > newest)
break; break;
@ -187,7 +188,7 @@ void CNetServerTurnManager::NotifyFinishedClientUpdate(CNetServerSession& sessio
m_ClientStateHashes.erase(m_ClientStateHashes.begin(), m_ClientStateHashes.lower_bound(newest+1)); m_ClientStateHashes.erase(m_ClientStateHashes.begin(), m_ClientStateHashes.lower_bound(newest+1));
} }
void CNetServerTurnManager::InitialiseClient(int client, u32 turn, bool observer) void CNetServerTurnManager::InitialiseClient(int client, turn_id_t turn, bool observer)
{ {
NETSERVERTURN_LOG("InitialiseClient(client=%d, turn=%d)\n", client, turn); NETSERVERTURN_LOG("InitialiseClient(client=%d, turn=%d)\n", client, turn);
@ -206,7 +207,7 @@ void CNetServerTurnManager::UninitialiseClient(int client)
bool checkOOS = m_ClientsData[client].isOOS; bool checkOOS = m_ClientsData[client].isOOS;
m_ClientsData.erase(client); m_ClientsData.erase(client);
for (std::pair<const u32, std::map<int, std::string>>& clientStateHash : m_ClientStateHashes) for (std::pair<const turn_id_t, std::map<int, std::string>>& clientStateHash : m_ClientStateHashes)
clientStateHash.second.erase(client); clientStateHash.second.erase(client);
// Check whether we're ready for the next turn now that we're not // Check whether we're ready for the next turn now that we're not
@ -228,8 +229,8 @@ void CNetServerTurnManager::SetTurnLength(u32 msecs)
m_TurnLength = msecs; m_TurnLength = msecs;
} }
u32 CNetServerTurnManager::GetSavedTurnLength(u32 turn) u32 CNetServerTurnManager::GetSavedTurnLength(turn_id_t turn)
{ {
ENSURE(turn <= m_ReadyTurn); ENSURE(turn <= m_ReadyTurn);
return m_SavedTurnLengths.at(turn); return m_SavedTurnLengths.at(static_cast<size_t>(turn));
} }

Some files were not shown because too many files have changed in this diff Show more