From 715a11e7581fd508842c355042abaaafa17745c0 Mon Sep 17 00:00:00 2001 From: Rene Peinthor Date: Sun, 25 Oct 2026 08:11:04 +0100 Subject: [PATCH 01/22] Add AGENTS.md with guidance for coding agents CLAUDE.md just imports AGENTS.md so Claude Code picks up the same instructions. --- AGENTS.md | 98 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 1 + 2 files changed, 99 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..801bfd13 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,98 @@ +# AGENTS.md + +Guidance for AI coding agents (and humans) working in this repository. + +## Project + +**pydcs** (`dcs` package): a Python framework to create, load, edit and save mission files (`.miz`) for +the flight simulator *DCS World*. A `.miz` is a zip archive with Lua tables (`mission`, `options`, +`warehouses`, `l10n/DEFAULT/dictionary`, `mapResource`, …) plus resources. License: LGPLv3. +Package version lives in `setup.py`. + +## Setup & commands + +Only runtime dependency is `pyproj` (`requirements.txt`). Use a virtualenv (`venv/` is gitignored): + +```sh +python3 -m venv venv +./venv/bin/pip install -r requirements.txt mypy flake8 pytest +``` + +CI (`.github/workflows/pythonpackage.yml`, runs on **Windows**, Python 3.10/3.11) does exactly this — +run all three before considering a change done: + +```sh +mypy dcs # type check (must be clean) +flake8 . # lint, config in setup.cfg +flake8 . --isolated --count --select=E9,F63,F7,F82 --show-source --statistics # syntax check of excluded files +pytest # tests/ and dcs/lua/test_parse.py +``` + +- Single test: `pytest tests/test_mission.py::BasicTests::test_basic_mission` +- Tests use paths relative to the repo root (e.g. `tests/missions/*.miz` as input, `missions/` as output + dir — gitignored), so run pytest from the repo root. +- On Linux, a few tests in `tests/liveries/`, `tests/test_installation.py` and `tests/test_unittype.py` + fail because they assume Windows paths/registry/encodings. Those failures are pre-existing; only + CI on Windows is authoritative for them. +- Packaging: `make build` (sdist + wheel), `make upload` (twine). + +## Code style + +- flake8 with max line length **127**, `max-complexity` 10 (C901 ignored). Generated data files, `tests/`, + `tools/` and `doc/` are excluded from flake8 (see `setup.cfg`). +- The package ships `py.typed`; new/changed code in `dcs/` should be type annotated and pass `mypy dcs`. +- Commit messages: short imperative summary line (e.g. "Fix TriggerZoneQuadPoint argument forwarding to + parent __init__"); data refreshes are titled "DCS export". + +## Architecture + +- `dcs/mission.py` — `Mission` is the entry point: `Mission(terrain)`, `load_file()`, `save()`, and + helpers to add groups/flights (`flight_group_inflight`, `awacs_flight`, `refuel_flight`, …). + Holds coalitions, countries, triggers, weather, translations, drawings, warehouses, etc. +- **Load/save pattern**: most model classes implement `load_from_dict(d)` / `create_from_dict(...)` to + read the parsed Lua table and `dict()` to produce it again. Round-tripping a mission without changes + must not lose data — when adding fields, implement both directions and add a load/save test. +- `dcs/lua/` — own Lua table parser (`parse.loads`) and serializer (`serialize.dumps`) used for all + `.miz` content. Serialization key order matters (ints numerically, then strings alphabetically). +- `dcs/translation.py` — `Translation`/`String`/`ResourceKey`: localized strings (`DictKey_*`) and + resource files embedded in the `.miz`. +- `dcs/country.py`, `dcs/coalition.py`, `dcs/unitgroup.py`, `dcs/unit.py`, `dcs/flyingunit.py`, + `dcs/point.py` — the object model of countries → groups → units / waypoints. +- `dcs/task.py`, `dcs/action.py`, `dcs/condition.py`, `dcs/triggers.py`, `dcs/goals.py` — waypoint + tasks/commands/options and the trigger system (rules, conditions, actions, trigger zones). +- `dcs/unittype.py` — base classes (`FlyingType`, `VehicleType`, `ShipType`, `StaticType`) for unit + type definitions; `dcs/payloads.py` and `dcs/liveries/` read payloads and liveries from the user's + DCS installation (`dcs/installation.py`, `dcs/winreg.py` — degrade gracefully off Windows). +- `dcs/terrain/` — `terrain.py` defines `Terrain`, `Airport`, `Runway`, `ParkingSlot`, `Warehouse`, + `Graph`; one subpackage per map (`caucasus`, `nevada`, `normandy`, `persiangulf`, `thechannel`, + `syria`, `marianaislands`, `falklands`, `sinai`, `kola`, `germany`), each with `.py` (the + `Terrain` subclass incl. bounds, projection, `utc_offset`), `airports.py` (generated airport data) + and `projection.py`. `dcs/mapping.py` handles points, vectors and lat/lon conversion via `pyproj`. +- `dcs/drawing/` — mission editor drawings (layers, lines, polygons, icons, text boxes). +- `dcs/scripts/` — example random-mission generators installed as console scripts (`dcs_random`, + `dcs_dogfight_wwii`, `dcs_oil_convoy`). + +## Generated files — don't hand-edit + +These are produced from a running DCS installation and get overwritten on the next export: + +- `dcs/planes.py`, `dcs/helicopters.py`, `dcs/vehicles.py`, `dcs/ships.py`, `dcs/statics.py`, + `dcs/weapons_data.py`, `dcs/countries.py` — generated by `tools/pydcs_export.lua` (run inside the DCS + mission editor). Fix the generator instead, and keep generated diffs out of unrelated changes. +- `dcs/terrain/*/airports.py` — generated via `tools/airport_import.py` (instructions in its docstring). +- `dcs/cloud_presets.py` — from `tools/weather_import.py`; projections from + `tools/export_map_projection.py`. + +These files are huge (`planes.py`, `countries.py` are ~40k lines) — search them, don't read them whole. + +## Adding a new terrain + +Add the subpackage under `dcs/terrain/`, export it in `dcs/terrain/__init__.py`, add the package to +`packages` in `setup.py`, and if the generated airport file needs it, add an `E126` per-file-ignore in +`setup.cfg`. Add a test in `tests/test_terrain.py`. + +## Other directories + +- `tests/` — pytest/unittest tests; sample missions in `tests/missions/` and `tests/*.miz`. +- `tools/` — data-export/import helpers and dev utilities (`missiondiff.py` diffs two `.miz` files). +- `doc/` — Sphinx docs (published on readthedocs). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..43c994c2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md From 8621d45cea0cb40aca724d2c136f8aacdff88787 Mon Sep 17 00:00:00 2001 From: Rene Peinthor Date: Sun, 25 Oct 2026 08:13:13 +0100 Subject: [PATCH 02/22] Test and declare support only for maintained Python versions (3.11-3.15) Python 3.10 reached end of life on 2026-10-01 and 3.15 was released on 2026-10-09. Update the CI matrix, setup.py classifiers and python_requires accordingly, and bump actions/checkout and actions/setup-python to v7. --- .github/workflows/pythonpackage.yml | 6 +++--- AGENTS.md | 2 +- setup.py | 8 +++++--- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 8f469615..51d44bd3 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -15,12 +15,12 @@ jobs: runs-on: windows-latest strategy: matrix: - python-version: ["3.10", "3.11"] + python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} - name: Install deps diff --git a/AGENTS.md b/AGENTS.md index 801bfd13..f5afaff0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ python3 -m venv venv ./venv/bin/pip install -r requirements.txt mypy flake8 pytest ``` -CI (`.github/workflows/pythonpackage.yml`, runs on **Windows**, Python 3.10/3.11) does exactly this — +CI (`.github/workflows/pythonpackage.yml`, runs on **Windows**, Python 3.11–3.15) does exactly this — run all three before considering a change done: ```sh diff --git a/setup.py b/setup.py index 6bdec778..a5c9dbad 100644 --- a/setup.py +++ b/setup.py @@ -26,12 +26,14 @@ 'Intended Audience :: Developers', 'Intended Audience :: End Users/Desktop', 'License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', + 'Programming Language :: Python :: 3.14', + 'Programming Language :: Python :: 3.15', 'Programming Language :: Python :: 3 :: Only' ], + python_requires='>=3.11', keywords='dcs digital combat simulator eagle dynamics mission framework', install_requires=[ 'pyproj' From 981028131106b20cd297bdb63a054aa151a80e34 Mon Sep 17 00:00:00 2001 From: Rene Peinthor Date: Sun, 25 Oct 2026 09:38:50 +0100 Subject: [PATCH 03/22] Port packaging from setup.py to pyproject.toml and build with uv Move all package metadata to pyproject.toml (setuptools backend) and drop setup.py and Manifest.in. The license is now declared as the SPDX expression LGPL-3.0-or-later, packages are discovered automatically and the city graph pickles are actually shipped (the old package_data listed file names that don't exist). The PyPI description now uses the full README. make build uses uv build; make upload keeps twine because it reads the credentials from ~/.pypirc. A new CI job builds the sdist and wheel with uv, runs twine check --strict and smoke tests the installed wheel. --- .github/workflows/pythonpackage.yml | 20 ++++++++ AGENTS.md | 13 +++-- Makefile | 5 +- Manifest.in | 2 - pyproject.toml | 42 +++++++++++++++++ setup.py | 73 ----------------------------- 6 files changed, 72 insertions(+), 83 deletions(-) delete mode 100644 Manifest.in create mode 100644 pyproject.toml delete mode 100644 setup.py diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 51d44bd3..dbf00d4b 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -41,3 +41,23 @@ jobs: run: | pip install pytest pytest + + package: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + - name: Set up uv + uses: astral-sh/setup-uv@v10.3.2 + - name: Build sdist and wheel + run: | + uv build + - name: Check package metadata + run: | + uvx twine check --strict dist/* + - name: Smoke test installed wheel + run: | + uv venv /tmp/wheel-test + uv pip install --python /tmp/wheel-test dist/*.whl + cd /tmp && /tmp/wheel-test/bin/python -c "import dcs; assert dcs.terrain.Caucasus().city_graph.nodes" diff --git a/AGENTS.md b/AGENTS.md index f5afaff0..42153291 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ Guidance for AI coding agents (and humans) working in this repository. **pydcs** (`dcs` package): a Python framework to create, load, edit and save mission files (`.miz`) for the flight simulator *DCS World*. A `.miz` is a zip archive with Lua tables (`mission`, `options`, `warehouses`, `l10n/DEFAULT/dictionary`, `mapResource`, …) plus resources. License: LGPLv3. -Package version lives in `setup.py`. +Package metadata and version live in `pyproject.toml` (setuptools backend, no `setup.py`). ## Setup & commands @@ -34,7 +34,10 @@ pytest # tests/ and dcs - On Linux, a few tests in `tests/liveries/`, `tests/test_installation.py` and `tests/test_unittype.py` fail because they assume Windows paths/registry/encodings. Those failures are pre-existing; only CI on Windows is authoritative for them. -- Packaging: `make build` (sdist + wheel), `make upload` (twine). +- Packaging: `make build` (`uv build`, sdist + wheel into `dist/`; `python -m build` works too), + `make upload` (twine, credentials from `~/.pypirc`). Packages are auto-discovered (`dcs*`); non-Python + files to ship must be listed under `[tool.setuptools.package-data]` in `pyproject.toml`. A separate CI + job (`package`, Linux) runs `uv build`, `twine check --strict` and a smoke test of the installed wheel. ## Code style @@ -87,9 +90,9 @@ These files are huge (`planes.py`, `countries.py` are ~40k lines) — search the ## Adding a new terrain -Add the subpackage under `dcs/terrain/`, export it in `dcs/terrain/__init__.py`, add the package to -`packages` in `setup.py`, and if the generated airport file needs it, add an `E126` per-file-ignore in -`setup.cfg`. Add a test in `tests/test_terrain.py`. +Add the subpackage (with `__init__.py`) under `dcs/terrain/` and export it in `dcs/terrain/__init__.py`; +packaging picks it up automatically. If the generated airport file needs it, add an `E126` +per-file-ignore in `setup.cfg`. Add a test in `tests/test_terrain.py`. ## Other directories diff --git a/Makefile b/Makefile index 97c45292..b9ea3129 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,10 @@ .PHONY: build upload clean build: - python3 setup.py sdist bdist_wheel + uv build upload: - python3 -m twine upload dist/* + uvx twine upload dist/* clean: rm -Rf dist/ - diff --git a/Manifest.in b/Manifest.in deleted file mode 100644 index 3e677d05..00000000 --- a/Manifest.in +++ /dev/null @@ -1,2 +0,0 @@ -include README.md -include LICENSE.txt diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..8d631833 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,42 @@ +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "pydcs" +version = "0.15.0" +description = "A Digital Combat Simulator mission builder framework" +readme = "README.md" +authors = [{ name = "Peinthor Rene", email = "peinthor@gmail.com" }] +license = "LGPL-3.0-or-later" +license-files = ["LICENSE.txt"] +requires-python = ">=3.11" +dependencies = ["pyproj"] +keywords = ["dcs", "digital combat simulator", "eagle dynamics", "mission", "framework"] +classifiers = [ + "Development Status :: 4 - Beta", + "Topic :: Games/Entertainment :: Simulation", + "Intended Audience :: Developers", + "Intended Audience :: End Users/Desktop", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", + "Programming Language :: Python :: 3 :: Only", +] + +[project.urls] +Homepage = "https://github.com/pydcs/dcs" +Documentation = "https://dcs.readthedocs.org/en/latest" + +[project.scripts] +dcs_random = "dcs.scripts.caucasus_random_mission:main" +dcs_dogfight_wwii = "dcs.scripts.dogfight_wwii:main" +dcs_oil_convoy = "dcs.scripts.destroy_oil_transport:main" + +[tool.setuptools.packages.find] +include = ["dcs*"] + +[tool.setuptools.package-data] +dcs = ["py.typed", "terrain/*/citygraph.p"] diff --git a/setup.py b/setup.py deleted file mode 100644 index a5c9dbad..00000000 --- a/setup.py +++ /dev/null @@ -1,73 +0,0 @@ -from setuptools import setup -from codecs import open -from os import path - -here = path.abspath(path.dirname(__file__)) - -long_description = '' -with open(path.join(here, 'README.md'), encoding='utf-8') as f: - for line in f: - if line.startswith('## Sample'): - break - long_description += line - -setup( - name="pydcs", - version='0.15.0', - description="A Digital Combat Simulator mission builder framework", - long_description=long_description, - url='https://github.com/pydcs/dcs', - author="Peinthor Rene", - author_email="peinthor@gmail.com", - license="LGPLv3", - classifiers=[ - 'Development Status :: 4 - Beta', - 'Topic :: Games/Entertainment :: Simulation', - 'Intended Audience :: Developers', - 'Intended Audience :: End Users/Desktop', - 'License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)', - 'Programming Language :: Python :: 3.11', - 'Programming Language :: Python :: 3.12', - 'Programming Language :: Python :: 3.13', - 'Programming Language :: Python :: 3.14', - 'Programming Language :: Python :: 3.15', - 'Programming Language :: Python :: 3 :: Only' - ], - python_requires='>=3.11', - keywords='dcs digital combat simulator eagle dynamics mission framework', - install_requires=[ - 'pyproj' - ], - packages=[ - 'dcs', - 'dcs/drawing', - 'dcs/liveries', - 'dcs/lua', - 'dcs/scripts', - 'dcs/terrain', - 'dcs/terrain/caucasus', - 'dcs/terrain/falklands', - 'dcs/terrain/germany', - 'dcs/terrain/kola', - 'dcs/terrain/marianaislands', - 'dcs/terrain/nevada', - 'dcs/terrain/normandy', - 'dcs/terrain/persiangulf', - 'dcs/terrain/projections', - 'dcs/terrain/sinai', - 'dcs/terrain/syria', - 'dcs/terrain/thechannel', - ], - package_data={ - 'dcs': ['py.typed'], - 'dcs/terrain': ['caucasus.p', 'nevada.p'], - }, - entry_points={ - 'console_scripts': [ - 'dcs_random=dcs.scripts.caucasus_random_mission:main', - 'dcs_dogfight_wwii=dcs.scripts.dogfight_wwii:main', - 'dcs_oil_convoy=dcs.scripts.destroy_oil_transport:main' - ] - }, - test_suite="tests" -) From 632ecb04d40685549e09265fe7e55ac9e50b5c74 Mon Sep 17 00:00:00 2001 From: Rene Peinthor Date: Sun, 25 Oct 2026 09:38:50 +0100 Subject: [PATCH 04/22] Fix dcs_random and dcs_oil_convoy scripts and city graph loading dcs_random pointed to caucasus_random_mission, which was deleted in 10a1b2a. Point it to nevada_random_mission instead and update README and docs to match. Both scripts no longer worked with the current API: - use terrain.airports[...] instead of the per-airport methods and default_{red,blue}_airports() removed in 3703eba - pass the terrain to Point, Rectangle and Polygon - use the current vehicle class names (renamed in the 2.7.0 export) - nevada_random_mission: drop an assert that broke inflight starts - destroy_oil_transport: allow the offered helicopter types, spread the multiplayer client groups over the blue airports once Senaki is full, and drop the nevada terrain choice, the mission is Caucasus only Graph.from_pickle now takes the terrain and rebinds the node positions to it, the pickles predate Point having a terrain. Nevada loaded its graph from the non-existent nevada.p instead of citygraph.p. --- README.md | 16 ++------ dcs/scripts/destroy_oil_transport.py | 61 ++++++++++++++++------------ dcs/scripts/nevada_random_mission.py | 33 +++++++-------- dcs/terrain/caucasus/caucasus.py | 2 +- dcs/terrain/nevada/nevada.py | 2 +- dcs/terrain/terrain.py | 12 +++++- doc/caucasus_random_mission.rst | 34 ++++++++-------- pyproject.toml | 2 +- tests/test_terrain.py | 13 ++++++ 9 files changed, 98 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index d55b8362..889ad5cc 100644 --- a/README.md +++ b/README.md @@ -35,19 +35,9 @@ pydcs comes with 2 proof of concept scripts: ### dcs_random -This script can generate 3 different mission types - - * refuel - - Generates a refuel mission for A-10C or M-2000C aircrafts, search your tanker and refuel. - - * CAS - - Support ground troops. - - * CAP - - Take care of your tanker and AWACS. +This script generates a random training mission on the Nevada map: player flight(s) for the +A-10C, M-2000C, Ka-50, AV-8B or F/A-18C at Nellis, Creech or Groom Lake, with AWACS, tankers and +civil air traffic around them. For options see the script help with `dcs_random --help` diff --git a/dcs/scripts/destroy_oil_transport.py b/dcs/scripts/destroy_oil_transport.py index 46ab35ae..d58315d9 100644 --- a/dcs/scripts/destroy_oil_transport.py +++ b/dcs/scripts/destroy_oil_transport.py @@ -15,7 +15,7 @@ def main(): choices=aircraft_types, help="Player aircraft type") parser.add_argument("-u", "--unhide", action="store_true", default=False, help="Show enemy pre mission") - parser.add_argument("-t", "--terrain", choices=["caucasus", "nevada"], default='caucasus') + parser.add_argument("-t", "--terrain", choices=["caucasus"], default='caucasus') parser.add_argument("-d", "--difficulty", choices=["easy", "normal", "hard", "ohno"], default='normal') parser.add_argument("-m", "--multiplayer", action="store_true", default=False) parser.add_argument("-s", "--stats", action="store_false", default=True) @@ -23,8 +23,7 @@ def main(): args = parser.parse_args() terrain_map = { - "caucasus": dcs.terrain.Caucasus, - "nevada": dcs.terrain.Nevada + "caucasus": dcs.terrain.Caucasus } difficulty_map = { 'easy': 0.25, @@ -36,7 +35,7 @@ def main(): terrain = terrain_map[args.terrain]() - zone_abkhazia = dcs.Polygon([ + zone_abkhazia = dcs.Polygon(terrain, [ dcs.Point(-187092.85714285, 460857.14285714, terrain), dcs.Point(-149378.57142856, 476285.71428571, terrain), dcs.Point(-147664.28571428, 520000, terrain), @@ -60,11 +59,18 @@ def main(): m.random_date() m.random_daytime('day') - for a in m.terrain.default_red_airports(): - a.set_red() + red_airports = [ + "Anapa-Vityazevo", "Krymsk", "Novorossiysk", "Krasnodar-Center", "Krasnodar-Pashkovsky", + "Maykop-Khanskaya", "Gelendzhik", "Mineralnye Vody", "Mozdok", "Beslan", "Nalchik", "Sochi-Adler" + ] + for a in red_airports: + m.terrain.airports[a].set_red() + + blue_airports = ["Batumi", "Vaziani", "Soganlug", "Kobuleti", "Senaki-Kolkhi", "Tbilisi-Lochini", "Kutaisi"] + for b in blue_airports: + m.terrain.airports[b].set_blue() - for b in m.terrain.default_blue_airports(): - b.set_blue() + senaki = m.terrain.airports["Senaki-Kolkhi"] city_graph = m.terrain.city_graph @@ -79,11 +85,11 @@ def main(): abkhazia = m.country(dcs.countries.Abkhazia.name) convoy_vehicles = [] for i in range(0, 1 + int(max(difficulty, random.random()) * 10)): - convoy_vehicles.append(dcs.vehicles.Unarmed.Fuel_Truck_ATZ_10) + convoy_vehicles.append(dcs.vehicles.Unarmed.ATZ_10) airdef = [ - dcs.countries.Abkhazia.Vehicle.AirDefence.AAA_ZU_23_on_Ural_375, - dcs.countries.Abkhazia.Vehicle.AirDefence.SPAAA_ZSU_23_4_Shilka + dcs.countries.Abkhazia.Vehicle.AirDefence.Ural_375_ZU_23, + dcs.countries.Abkhazia.Vehicle.AirDefence.ZSU_23_4_Shilka ] if random.random() < difficulty: convoy_vehicles.append(random.choice(airdef)) @@ -101,9 +107,9 @@ def main(): _, path = city_graph.travel(oil_convoy, start_node, destination_node, 100) # add light air defence around and in cities on path - aaa_def = [[dcs.countries.Abkhazia.Vehicle.AirDefence.AAA_ZU_23_Emplacement], - [dcs.countries.Abkhazia.Vehicle.AirDefence.SAM_SA_18_Igla_MANPADS, - dcs.countries.Abkhazia.Vehicle.AirDefence.SAM_SA_18_Igla_comm]] + aaa_def = [[dcs.countries.Abkhazia.Vehicle.AirDefence.ZU_23_Emplacement], + [dcs.countries.Abkhazia.Vehicle.AirDefence.SA_18_Igla_manpad, + dcs.countries.Abkhazia.Vehicle.AirDefence.SA_18_Igla_comm]] for city in city_graph.rated_nodes_within(zone_enemy, 50): use_building_pos = int(min(difficulty, random.random()) * len(city.air_defence_pos_small)) small_aaa_pos = list(city.air_defence_pos_small) @@ -118,9 +124,9 @@ def main(): vg.hidden = not args.unhide vg.formation_scattered(random.randrange(0, 360), 15) - aaa_def += [[dcs.countries.Abkhazia.Vehicle.AirDefence.SPAAA_ZSU_23_4_Shilka, - dcs.countries.Abkhazia.Vehicle.Armor.ARV_BRDM_2, - dcs.countries.Abkhazia.Vehicle.Armor.ARV_BRDM_2]] + aaa_def += [[dcs.countries.Abkhazia.Vehicle.AirDefence.ZSU_23_4_Shilka, + dcs.countries.Abkhazia.Vehicle.Armor.BRDM_2, + dcs.countries.Abkhazia.Vehicle.Armor.BRDM_2]] for node in city_graph.nodes_within(zone_enemy): if random.random() < (difficulty - 0.1): vg = m.vehicle_group_platoon(abkhazia, @@ -146,12 +152,12 @@ def main(): # place farp for helis zugdidi_ne = city_graph.node('Zugidi NE') - farp = m.farp(usa, "FARP Zugdidi", zugdidi_ne.position + dcs.mapping.Point(0, 500), callsign_id=1) + farp = m.farp(usa, "FARP Zugdidi", zugdidi_ne.position + dcs.mapping.Point(0, 500, m.terrain), callsign_id=1) fg = m.flight_group_from_unit( usa, dcs.helicopters.Ka_50.id + " Client", dcs.helicopters.Ka_50, farp, dcs.task.CAS, group_size=2) fg.set_client() - farp = m.farp(usa, "FARP Kvemo", zugdidi_ne.position + dcs.mapping.Point(0, -22 * 1000), callsign_id=2) + farp = m.farp(usa, "FARP Kvemo", zugdidi_ne.position + dcs.mapping.Point(0, -22 * 1000, m.terrain), callsign_id=2) fg = m.flight_group_from_unit( usa, dcs.helicopters.UH_1H.id + " Client", dcs.helicopters.UH_1H, farp, dcs.task.CAS, group_size=2) fg.set_client() @@ -159,22 +165,27 @@ def main(): # place player player_fg = None if args.multiplayer: + # senaki first, the other blue airports take the client groups that no longer fit there + client_airports = [senaki] + [m.terrain.airports[b] for b in blue_airports if b != "Senaki-Kolkhi"] for x in aircrafts: if dcs.task.CAS in x.tasks and x not in [dcs.planes.Su_33, dcs.planes.MiG_29A, dcs.planes.MiG_29S]: + airport = next((a for a in client_airports if len(a.free_parking_slots(x)) >= 2), None) + if airport is None: + continue country = m.country(dcs.countries.Georgia.name) if x in [dcs.planes.Su_25T, dcs.planes.Su_25] else usa fg = m.flight_group_from_airport( - country, x.id + " Client", x, m.terrain.senaki_kolkhi(), dcs.task.CAS, group_size=2) - fg.add_runway_waypoint(m.terrain.senaki_kolkhi()) + country, x.id + " Client", x, airport, dcs.task.CAS, group_size=2) + fg.add_runway_waypoint(airport) fg.set_client() else: - aircraft_type = [x for x in aircrafts if x.id == args.aircrafttype][0] - player_fg = m.flight_group_from_airport(usa, "Player", aircraft_type, m.terrain.senaki_kolkhi(), dcs.task.CAS) - player_fg.add_runway_waypoint(m.terrain.senaki_kolkhi()) + aircraft_type = [x for x in aircrafts + helicopters if x.id == args.aircrafttype][0] + player_fg = m.flight_group_from_airport(usa, "Player", aircraft_type, senaki, dcs.task.CAS) + player_fg.add_runway_waypoint(senaki) player_fg.units[0].set_player() # CAP flight cap_type = random.choice([dcs.planes.F_16C_bl_52d, dcs.planes.F_A_18C, dcs.planes.F_15E]) - m.patrol_flight(usa, "CAP", cap_type, m.terrain.kutaisi(), zugdidi_ne.position, + m.patrol_flight(usa, "CAP", cap_type, m.terrain.airports["Kutaisi"], zugdidi_ne.position, zugdidi_ne.position.point_from_heading(random.randint(270, 350), 35 * 1000)) notify_nodes = path[1:5] diff --git a/dcs/scripts/nevada_random_mission.py b/dcs/scripts/nevada_random_mission.py index 6e22f649..36c739bf 100644 --- a/dcs/scripts/nevada_random_mission.py +++ b/dcs/scripts/nevada_random_mission.py @@ -101,8 +101,8 @@ def __init__(self, aircraft_types: List[Tuple[str, str]], playercount: int, star pg: Optional[FlyingGroup] = None for pg in player_groups: - assert pg.points[0].airdrome_id is not None - airport = self.m.terrain.airport_by_id(pg.points[0].airdrome_id) + airdrome_id = pg.points[0].airdrome_id + airport = self.m.terrain.airport_by_id(airdrome_id) if airdrome_id is not None else None if not airport: airport = random.choice(blue_military) @@ -156,8 +156,8 @@ def daytime(self, period): def setup_airports(self): nevada = self.m.terrain # type: dcs.terrain.Nevada - self.blue_airports = nevada.default_blue_airports() - self.red_airports = [nevada.tonopah(), nevada.mina(), nevada.tonopah_test_range()] + self.blue_airports = list(nevada.airport_list()) + self.red_airports = [nevada.airports[x] for x in ["Tonopah", "Mina", "Tonopah Test Range"]] for a in self.blue_airports: self.setup_airport(a, "blue", []) for a in self.red_airports: @@ -191,14 +191,14 @@ def civil_flight(countries, airports): slots = airport.free_parking_slot(ptype) - beatty = self.m.terrain.beatty() - groom = self.m.terrain.groom_lake() - x = random.randrange(int(dcs.terrain.Nevada.bounds.bottom) + 100 * 1000, + beatty = self.m.terrain.airports["Beatty"] + groom = self.m.terrain.airports["Groom Lake"] + x = random.randrange(int(self.m.terrain.bounds.bottom) + 100 * 1000, int(groom.position.x)) y = random.randrange(int(beatty.position.y), - int(dcs.terrain.Nevada.bounds.right) - 130 * 1000) + int(self.m.terrain.bounds.right) - 130 * 1000) - pos = dcs.mapping.Point(x, y) + pos = dcs.mapping.Point(x, y, self.m.terrain) name = "Civil " + str(c_count) rand = random.random() if 0.3 < rand < 0.5 and slots: @@ -211,16 +211,17 @@ def civil_flight(countries, airports): pg = self.m.flight_group_from_airport(country, name, ptype, airport) pg.uncontrolled = True else: - bound = dcs.mapping.Rectangle(dcs.terrain.Nevada.bounds.top - 100 * 1000, - dcs.terrain.Nevada.bounds.left + 200 * 1000, - dcs.terrain.Nevada.bounds.bottom + 100 * 1000, - dcs.terrain.Nevada.bounds.right - 130 * 1000) + bound = dcs.mapping.Rectangle(self.m.terrain.bounds.top - 100 * 1000, + self.m.terrain.bounds.left + 200 * 1000, + self.m.terrain.bounds.bottom + 100 * 1000, + self.m.terrain.bounds.right - 130 * 1000, + self.m.terrain) point = bound.random_point() pg = self.m.flight_group_inflight( country, name, ptype, point, random.randrange(5000, 8000, 100), 400 ) - tmp = pg.add_waypoint(dcs.mapping.Point(0, 0), pg.points[0].alt) + tmp = pg.add_waypoint(dcs.mapping.Point(0, 0, self.m.terrain), pg.points[0].alt) wp = pg.add_runway_waypoint(airport, distance=random.randrange(6000, 8000, 100)) heading = wp.position.heading_between_point(point) tmp.position = wp.position.point_from_heading(heading, 30 * 1000) @@ -357,7 +358,7 @@ def add_sa6_site(self, country, position): def add_ground_targets(self): russia = self.m.country(dcs.countries.Russia.name) - pos1 = self.m.terrain.groom_lake().position.point_from_heading(5, 60 * 1000) + pos1 = self.m.terrain.airports["Groom Lake"].position.point_from_heading(5, 60 * 1000) rus_vehicles = dcs.countries.Russia.Vehicle vtypes = [ rus_vehicles.Armor.T_72B, @@ -397,7 +398,7 @@ def add_ground_targets(self): jtac1.units[0].player_can_drive = True # these are targets with light air defense in sector 74C - pos = self.m.terrain.groom_lake().position.point_from_heading(320, 53 * 1000) + pos = self.m.terrain.airports["Groom Lake"].position.point_from_heading(320, 53 * 1000) self.m.vehicle_group(russia, "ref 2", rus_vehicles.Infantry.Infantry_AK, pos) vtypes_light = [ rus_vehicles.AirDefence.Ural_375_ZU_23, diff --git a/dcs/terrain/caucasus/caucasus.py b/dcs/terrain/caucasus/caucasus.py index 68378893..a768071f 100644 --- a/dcs/terrain/caucasus/caucasus.py +++ b/dcs/terrain/caucasus/caucasus.py @@ -40,7 +40,7 @@ def __init__(self): self.bullseye_red = {"x": 11557, "y": 371700} try: - self.city_graph: Graph = Graph.from_pickle(os.path.join(os.path.dirname(__file__), 'citygraph.p')) + self.city_graph: Graph = Graph.from_pickle(os.path.join(os.path.dirname(__file__), 'citygraph.p'), self) except FileNotFoundError: pass diff --git a/dcs/terrain/nevada/nevada.py b/dcs/terrain/nevada/nevada.py index c41f40ba..f1b304c5 100644 --- a/dcs/terrain/nevada/nevada.py +++ b/dcs/terrain/nevada/nevada.py @@ -40,7 +40,7 @@ def __init__(self): self.bullseye_red = {"x": -288293.969, "y": -88022.641} try: - self.city_graph = Graph.from_pickle(os.path.join(os.path.dirname(__file__), 'nevada.p')) # type: Graph + self.city_graph = Graph.from_pickle(os.path.join(os.path.dirname(__file__), 'citygraph.p'), self) # type: Graph except FileNotFoundError: pass diff --git a/dcs/terrain/terrain.py b/dcs/terrain/terrain.py index 49eb0df0..0f76b9d0 100644 --- a/dcs/terrain/terrain.py +++ b/dcs/terrain/terrain.py @@ -407,9 +407,17 @@ def add_edge(self, from_node: Node, to_node: Node, distance: int, on_road: bool self.edge_properties[(from_node.name, to_node.name)] = (distance, on_road) @staticmethod - def from_pickle(pickle_file): + def from_pickle(pickle_file, terrain: Terrain) -> Graph: with open(pickle_file, 'rb') as f: - return pickle.load(f) + graph = pickle.load(f) + # The pickled positions only carry coordinates, rebind them to the terrain. + # Older pickles also lack the air defence positions. + for node in graph.nodes: + node.position = mapping.Point(node.position.x, node.position.y, terrain) + node.air_defence_pos_small = [ + mapping.Point(p.x, p.y, terrain) for p in getattr(node, 'air_defence_pos_small', []) + ] + return graph def _dijkstra(self, initial): visited = {initial: 0} diff --git a/doc/caucasus_random_mission.rst b/doc/caucasus_random_mission.rst index 03c59b43..0eb7048f 100644 --- a/doc/caucasus_random_mission.rst +++ b/doc/caucasus_random_mission.rst @@ -1,30 +1,28 @@ Random mission script ===================== -pydcs delivers a small random mission creator script. -This script was indented to show features of the framework and is also a little testbed. +pydcs delivers a small random mission creator script, installed as ``dcs_random``. +It generates a random training mission on the Nevada map. +This script was intended to show features of the framework and is also a little testbed. It is invoked by the commandline with the following arguments:: - usage: caucasus_random_mission.py [-h] [-a {A-10C,Su-25T,M-2000C,Ka-50,MiG-21Bis}] - [-p PLAYERCOUNT] [-s {inflight,runway,warm,cold}] - [-t {main,CAS,CAP,refuel}] - [-d {random,day,night,dusk,dawn,noon}] - [-w {dynamic,dyncyclone,dynanti,dynone,clear}] [-u] - [--show-stats] [-o OUTPUT] + usage: dcs_random [-h] [-a {A-10C,M-2000C,Ka-50,AV8BNA,FA-18C_hornet}] + [-p PLAYERCOUNT] [-s {inflight,runway,warm,cold}] + [-d {random,day,night,dusk,dawn,noon}] + [-w {dynamic,dyncyclone,dynanti,dynone,clear}] [-u] + [--show-stats] [-o OUTPUT] - Random DCS mission generator + Nevada random training mission generator - optional arguments: + options: -h, --help show this help message and exit - -a {A-10C,Su-25T,M-2000C,Ka-50,MiG-21Bis}, --aircrafttype {A-10C,Su-25T,M-2000C,Ka-50,MiG-21Bis} + -a, --aircrafttype {A-10C,M-2000C,Ka-50,AV8BNA,FA-18C_hornet} Player aircraft type - -p PLAYERCOUNT, --playercount PLAYERCOUNT - -s {inflight,runway,warm,cold}, --start {inflight,runway,warm,cold} - -t {main,CAS,CAP,refuel}, --missiontype {main,CAS,CAP,refuel} - -d {random,day,night,dusk,dawn,noon}, --daytime {random,day,night,dusk,dawn,noon} - -w {dynamic,dyncyclone,dynanti,dynone,clear}, --weather {dynamic,dyncyclone,dynanti,dynone,clear} + -p, --playercount PLAYERCOUNT + -s, --start {inflight,runway,warm,cold} + -d, --daytime {random,day,night,dusk,dawn,noon} + -w, --weather {dynamic,dyncyclone,dynanti,dynone,clear} -u, --unhide Show enemy pre mission --show-stats Show generated missions stats - -o OUTPUT, --output OUTPUT - Name and path of the generated mission + -o, --output OUTPUT Name and path of the generated mission diff --git a/pyproject.toml b/pyproject.toml index 8d631833..6b43eff3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ Homepage = "https://github.com/pydcs/dcs" Documentation = "https://dcs.readthedocs.org/en/latest" [project.scripts] -dcs_random = "dcs.scripts.caucasus_random_mission:main" +dcs_random = "dcs.scripts.nevada_random_mission:main" dcs_dogfight_wwii = "dcs.scripts.dogfight_wwii:main" dcs_oil_convoy = "dcs.scripts.destroy_oil_transport:main" diff --git a/tests/test_terrain.py b/tests/test_terrain.py index 9d4d646e..0e9e5e3a 100644 --- a/tests/test_terrain.py +++ b/tests/test_terrain.py @@ -70,9 +70,22 @@ def test_parking_mixed_used(self): self.assertEqual(len(used)+2, len(m.terrain.airports["Batumi"].parking_slots)) + def test_city_graph(self): + terrain = dcs.terrain.Caucasus() + node = terrain.city_graph.node('Adler') + # positions must be bound to the terrain to be usable for geometry + self.assertIsNotNone(node.position.random_point_within(50)) + self.assertAlmostEqual(node.position.latlng().lat, 43.4, places=1) + class NevadaTest(unittest.TestCase): + def test_city_graph(self): + terrain = dcs.terrain.Nevada() + self.assertEqual(len(terrain.city_graph.nodes), 4) + for node in terrain.city_graph.nodes: + self.assertIsNotNone(node.position.random_point_within(50)) + def test_parking_slots(self): m = dcs.mission.Mission(terrain=dcs.terrain.Nevada()) slots = m.terrain.airports["Nellis"].free_parking_slots(dcs.planes.A_10C) From 667443f5afef4cae026616f15c22c1ba288be3d7 Mon Sep 17 00:00:00 2001 From: Rene Peinthor Date: Sun, 25 Oct 2026 09:46:32 +0100 Subject: [PATCH 05/22] Fix livery scanning on non-Windows platforms and run CI on Linux too The livery ID was taken by splitting the path on backslashes, so on Linux it became the full path. Use os.path.basename instead. The 'ansi' codec used as fallback for description.lua only exists on Windows; elsewhere decoding raised an uncaught LookupError and aborted the scan. Treat a missing codec like a decode failure and fall back to cp1252. The install directory tests now expect os.path.sep, and the tests for genuinely Windows-only behavior (ansi codec, Steam library parsing) are skipped on other platforms. Add ubuntu-latest to the CI matrix so these platform issues are caught. --- .github/workflows/pythonpackage.yml | 3 ++- AGENTS.md | 8 ++++---- dcs/liveries/livery.py | 7 ++++--- tests/liveries/test_livery.py | 12 ++++++++++++ tests/test_installation.py | 9 ++++++--- 5 files changed, 28 insertions(+), 11 deletions(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index dbf00d4b..246cd000 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -12,9 +12,10 @@ on: jobs: build: - runs-on: windows-latest + runs-on: ${{ matrix.os }} strategy: matrix: + os: [windows-latest, ubuntu-latest] python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"] steps: diff --git a/AGENTS.md b/AGENTS.md index 42153291..6d24ad95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ python3 -m venv venv ./venv/bin/pip install -r requirements.txt mypy flake8 pytest ``` -CI (`.github/workflows/pythonpackage.yml`, runs on **Windows**, Python 3.11–3.15) does exactly this — +CI (`.github/workflows/pythonpackage.yml`, runs on **Windows and Linux**, Python 3.11–3.15) does exactly this — run all three before considering a change done: ```sh @@ -31,9 +31,9 @@ pytest # tests/ and dcs - Single test: `pytest tests/test_mission.py::BasicTests::test_basic_mission` - Tests use paths relative to the repo root (e.g. `tests/missions/*.miz` as input, `missions/` as output dir — gitignored), so run pytest from the repo root. -- On Linux, a few tests in `tests/liveries/`, `tests/test_installation.py` and `tests/test_unittype.py` - fail because they assume Windows paths/registry/encodings. Those failures are pre-existing; only - CI on Windows is authoritative for them. +- The suite must pass on Windows and Linux. Use `os.path` / `pathlib` instead of hard-coded `\\` + separators. Tests for genuinely Windows-only behavior (e.g. the `ansi` codec, Steam library parsing) + are gated with `@pytest.mark.skipif(sys.platform != "win32", ...)`. - Packaging: `make build` (`uv build`, sdist + wheel into `dist/`; `python -m build` works too), `make upload` (twine, credentials from `~/.pypirc`). Packages are auto-discovered (`dcs*`); non-Python files to ship must be listed under `[tool.setuptools.package-data]` in `pyproject.toml`. A separate CI diff --git a/dcs/liveries/livery.py b/dcs/liveries/livery.py index bd432fa8..3a19351e 100644 --- a/dcs/liveries/livery.py +++ b/dcs/liveries/livery.py @@ -9,11 +9,12 @@ def _attempt_read_from_filestream(filestream: bytes) -> Optional[str]: - encodes = ["utf-8", "ansi"] + # 'ansi' (the active Windows code page) only exists on Windows, cp1252 is the fallback elsewhere + encodes = ["utf-8", "ansi", "cp1252"] for enc in encodes: try: code = filestream.decode(enc) - except UnicodeDecodeError: + except (UnicodeDecodeError, LookupError): continue return code return None @@ -71,7 +72,7 @@ def from_lua(code: str, path: str) -> Optional[Livery]: :param unit: The name of the unit as 'DCS type', i.e. name of the unit inside the Liveries folder, e.g. 'A-10CII' """ - path_id = path.split("\\")[-1].replace(".zip", "") + path_id = os.path.basename(path).replace(".zip", "") if path_id == "placeholder": return None diff --git a/tests/liveries/test_livery.py b/tests/liveries/test_livery.py index f6894993..b4c929c9 100644 --- a/tests/liveries/test_livery.py +++ b/tests/liveries/test_livery.py @@ -1,6 +1,9 @@ +import sys from pathlib import Path from zipfile import ZipFile +import pytest + from dcs.liveries.livery import Livery from dcs.liveries.liveryset import LiverySet @@ -66,6 +69,7 @@ def test_livery_valid_for_country() -> None: assert Livery("", "", 0, {"USA", "RUS"}).valid_for_country("RUS") +@pytest.mark.skipif(sys.platform != "win32", reason="the 'ansi' codec only exists on Windows") def test_ansi_encoded_description(tmp_path: Path) -> None: (tmp_path / "description.lua").write_bytes("name = 'Š'".encode("ansi")) livery = Livery.from_path(str(tmp_path)) @@ -73,6 +77,14 @@ def test_ansi_encoded_description(tmp_path: Path) -> None: assert livery.name == "Š" +@pytest.mark.skipif(sys.platform == "win32", reason="Windows decodes with the 'ansi' codec first") +def test_cp1252_encoded_description_without_ansi_codec(tmp_path: Path) -> None: + (tmp_path / "description.lua").write_bytes("name = 'Š'".encode("cp1252")) + livery = Livery.from_path(str(tmp_path)) + assert livery is not None + assert livery.name == "Š" + + def test_find_unzipped_livery(tmp_path: Path) -> None: description = tmp_path / "description.lua" description.touch() diff --git a/tests/test_installation.py b/tests/test_installation.py index 27f8c384..3b3fecdb 100644 --- a/tests/test_installation.py +++ b/tests/test_installation.py @@ -1,3 +1,5 @@ +import os +import sys import textwrap from pathlib import Path from typing import Any, Callable, Iterator, Optional, TypeVar @@ -97,15 +99,16 @@ def none_installed_fixture() -> Iterator[None]: def test_get_dcs_install_directory_stable(stable_dcs_install: Path) -> None: - assert get_dcs_install_directory() == f"{stable_dcs_install}\\" + assert get_dcs_install_directory() == f"{stable_dcs_install}{os.path.sep}" def test_get_dcs_install_directory_beta(beta_dcs_install: Path) -> None: - assert get_dcs_install_directory() == f"{beta_dcs_install}\\" + assert get_dcs_install_directory() == f"{beta_dcs_install}{os.path.sep}" +@pytest.mark.skipif(sys.platform != "win32", reason="Steam library folders are parsed as Windows drive paths") def test_get_dcs_install_directory_steam(steam_dcs_install: Path) -> None: - assert get_dcs_install_directory() == f"{steam_dcs_install}\\" + assert get_dcs_install_directory() == f"{steam_dcs_install}{os.path.sep}" def test_get_dcs_install_directory_not_installed(none_installed: None) -> None: From 71a4becd24efffd2101d0ae9330543026168ba18 Mon Sep 17 00:00:00 2001 From: Rene Peinthor Date: Sun, 25 Oct 2026 10:14:59 +0100 Subject: [PATCH 06/22] Fix Lua number and string handling in parser and serializer Parser: - accept a sign after the exponent (1e+20); Python writes large floats that way, so pydcs could not read back numbers it wrote itself - parse integers with int() so large values keep their precision - raise SyntaxError instead of ValueError for malformed numbers, which also stops a single bad livery description.lua from aborting the whole livery scan - decode escape sequences like the Lua 5.1 lexer (\n, \r, \t, \ddd, escaped line breaks) instead of keeping the escaped character as is - accept single quoted table keys Serializer: - escape string keys the same way as string values - escape carriage returns, a raw CR in a quoted string is a Lua syntax error and made the mission unloadable in DCS - write +-inf as +-1e309 (reads back as infinity in Lua and pydcs) and reject nan instead of writing an undefined variable --- dcs/lua/parse.py | 58 +++++++++++++++++++++++++++-------------- dcs/lua/serialize.py | 24 +++++++++++++---- dcs/lua/test_parse.py | 20 ++++++++++++++ tests/test_serialize.py | 22 ++++++++++++++++ 4 files changed, 100 insertions(+), 24 deletions(-) diff --git a/dcs/lua/parse.py b/dcs/lua/parse.py index a1d9158e..35f15453 100644 --- a/dcs/lua/parse.py +++ b/dcs/lua/parse.py @@ -1,6 +1,9 @@ from typing import Dict, Any, Callable, Optional, List, Union +_ESCAPES = {'n': '\n', 'r': '\r', 't': '\t', 'a': '\a', 'b': '\b', 'f': '\f', 'v': '\v'} + + def loads( tablestr, _globals: Optional[Dict[str, Any]] = None, @@ -175,7 +178,20 @@ def string(self) -> str: else: s += c elif state == 2: - s += c + # escape sequences as handled by the lua 5.1 lexer + if c.isdigit(): + digits = c + while len(digits) < 3 and self.char(1).isdigit(): + self.pos += 1 + digits += self.char() + s += chr(int(digits)) + elif c in ('\n', '\r'): + # escaped line break, a \r\n or \n\r pair counts as one + if self.char(1) in ('\n', '\r') and self.char(1) != c: + self.pos += 1 + s += '\n' + else: + s += _ESCAPES.get(c, c) state = 0 return s @@ -187,32 +203,36 @@ def number(self) -> Union[int, float]: if self.advance(): raise self.eob_exception() - found_exponent, found_exponent_sign, found_separator = False, False, False + found_exponent, found_separator = False, False while not self.eob(): - if self.char().isnumeric(): + c = self.char() + if c.isdigit(): pass - elif self.char() == '.': - if not found_separator: - found_separator = True - else: + elif c == '.': + if found_separator or found_exponent: raise SyntaxError() - elif self.char().lower() == 'e': - if not found_exponent: - found_exponent = True - else: - raise SyntaxError() - elif self.char() == '-': - if not found_exponent_sign: - found_exponent_sign = True - else: + found_separator = True + elif c.lower() == 'e': + if found_exponent: raise SyntaxError() + found_exponent = True + elif c in '+-': + # a sign is only valid directly after the exponent marker, e.g. 1e+20 or 2.5e-3 + if not n or n[-1].lower() != 'e': + break else: break - n += self.char() + n += c self.pos += 1 - num = float(n) * sign + try: + if not found_separator and not found_exponent: + # parse integers directly, going through float would lose precision of large values + return int(n) * sign + num = float(n) * sign + except ValueError as ex: + raise SyntaxError("Invalid number '{}' in line {}".format(n, self.lineno)) from ex if num.is_integer(): return int(num) return num @@ -249,7 +269,7 @@ def _object(self) -> Dict[Union[int, str], Any]: self.eat_ws() key: Union[int, str] - if self.char() == '"': + if self.char() in ('"', "'"): key = self.string() else: number = self.number() diff --git a/dcs/lua/serialize.py b/dcs/lua/serialize.py index 9a0999a2..5a21254a 100644 --- a/dcs/lua/serialize.py +++ b/dcs/lua/serialize.py @@ -1,3 +1,15 @@ +import math + + +def _quote(value: str) -> str: + v = value.replace('\\', '\\\\') + v = v.replace('"', '\\"') + v = v.replace('\n', '\\\n') + # a raw carriage return inside a quoted string is a syntax error in lua + v = v.replace('\r', '\\r') + return '"{val}"'.format(val=v) + + def dumps(value, varname=None, indent=None): nl = "\n" if indent else "" s = varname + '=' + nl if varname else '' @@ -11,7 +23,7 @@ def dumps(value, varname=None, indent=None): child = value[key] KNL = "\n" if indent and isinstance(child, (dict, list)) else "" selem = '\t' * indentcount - skey = key if isinstance(key, int) else '"{key}"'.format(key=key) + skey = key if isinstance(key, int) else _quote(str(key)) selem += '[{key}]={nl}'.format(key=skey, nl=KNL) selem += dumps(value[key], indent=indent + 1 if indent else None) e.append(selem) @@ -34,14 +46,16 @@ def dumps(value, varname=None, indent=None): s += nl + ",{nl}".format(nl=nl).join(e) s += nl + '\t' * (indentcount - 1) + "}" elif isinstance(value, str): - v = value.replace('\\', '\\\\') - v = v.replace('"', '\\"') - v = v.replace('\n', '\\\n') - s += '"{val}"'.format(val=v) + s += _quote(value) elif isinstance(value, bool): s += "true" if value else "false" elif value is None: s += "nil" + elif isinstance(value, float) and not math.isfinite(value): + # Lua has no literal for these: an overflowing number reads back as infinity, nan can't be expressed + if math.isnan(value): + raise ValueError("NaN can not be serialized to lua") + s += "1e309" if value > 0 else "-1e309" else: s += str(value) diff --git a/dcs/lua/test_parse.py b/dcs/lua/test_parse.py index 30048640..6dae86c2 100644 --- a/dcs/lua/test_parse.py +++ b/dcs/lua/test_parse.py @@ -341,6 +341,26 @@ def test_float_beginning_with_dot(self) -> None: r = loads("num = .1") self.assertEqual(r["num"], 0.1) + def test_number_exponents(self) -> None: + r = loads("a = 1e+5 b = 2.5E-3 c = -1e+20 d = 3e2") + self.assertEqual(r, {"a": 100000, "b": 0.0025, "c": -1e20, "d": 300}) + + def test_large_integer_keeps_precision(self) -> None: + self.assertEqual(loads("a = 12345678901234567890")["a"], 12345678901234567890) + + def test_invalid_number_raises_syntax_error(self) -> None: + for code in ["a = 1.2.3", "a = 1e5e5", "a = -"]: + with self.subTest(code), self.assertRaises(SyntaxError): + loads(code) + + def test_string_escapes(self) -> None: + # as written by lua's %q format: \r, \000 and an escaped line break + r = loads('a = "x\\r\\\ny\\000z\\tq\\"e\\\\"') + self.assertEqual(r["a"], 'x\r\ny\x00z\tq"e\\') + + def test_single_quoted_key(self) -> None: + self.assertEqual(loads("a = {['k'] = 1}")["a"], {"k": 1}) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_serialize.py b/tests/test_serialize.py index 5d3477ef..a0b9fc2a 100644 --- a/tests/test_serialize.py +++ b/tests/test_serialize.py @@ -1,4 +1,5 @@ import unittest +from dcs.lua.parse import loads from dcs.lua.serialize import dumps class TestLuaSerialize(unittest.TestCase): @@ -23,3 +24,24 @@ def test_some_key_is_string_should_arrange_in_alphabetical_order(self): dumped = dumps(original, 'v') self.assertEqual('v={[1]=1,[10]=3,[3]=2,["x"]=4}', dumped) + + def test_round_trip_of_large_and_small_floats(self): + original = {"a": 1e20, "b": -1.5e300, "c": 2.5e-300, "d": 12345678901234567890} + + self.assertEqual(original, loads(dumps(original, 'v'))['v']) + + def test_infinity_round_trips_and_nan_is_rejected(self): + original = {"a": float("inf"), "b": float("-inf")} + + self.assertEqual(original, loads(dumps(original, 'v'))['v']) + with self.assertRaises(ValueError): + dumps({"a": float("nan")}, 'v') + + def test_special_characters_in_strings_and_keys(self): + original = {'quote"key': 'a"b', 'back\\slash': 'c\\d', 'cr\rlf\n': 'line1\r\nline2'} + + dumped = dumps(original, 'v') + + self.assertNotIn('\r', dumped) + self.assertEqual(original, loads(dumped)['v']) + From 4acb37243a1282dd747f7c789e89f2d0272e2c1c Mon Sep 17 00:00:00 2001 From: Rene Peinthor Date: Sun, 25 Oct 2026 10:15:03 +0100 Subject: [PATCH 07/22] Load all known tasks and keep unknown ones instead of failing Missions written by pydcs could not be loaded again when they used RecoveryTanker, NoTask or OptJettisonEmptyTanks (missing from the lookup tables) or FACAttackGroup (no constructor defaults). Register the missing classes and give FACAttackGroup and RecoveryTanker a create_from_dict. Tasks, options and wrapped actions pydcs doesn't know now load as EmptyTaskAction with their raw params and are saved back unchanged, instead of aborting the whole mission load with a KeyError. --- dcs/task.py | 30 +++++++++++++++++++---- tests/test_task.py | 59 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 tests/test_task.py diff --git a/dcs/task.py b/dcs/task.py index 6d79b9a3..972e4a3b 100644 --- a/dcs/task.py +++ b/dcs/task.py @@ -17,17 +17,21 @@ def _create_from_dict(d: TaskDict) -> 'Task': + # Unknown tasks, options and actions are kept as EmptyTaskAction with their raw params, + # so missions using things pydcs doesn't know (yet) still load and save unchanged. _id = d["id"] + cls: Type[Task] if _id == "WrappedAction": actionid = d["params"]["action"]["id"] if actionid == "Option": - t = options[d["params"]["action"]["params"]["name"]].create_from_dict(d) + cls = options.get(d["params"]["action"]["params"]["name"], EmptyTaskAction) else: - t = wrappedactions[actionid].create_from_dict(d) + cls = wrappedactions.get(actionid, EmptyTaskAction) elif _id == "EngageTargets": - t = engagetargets_tasks[d.get("key")].create_from_dict(d) + cls = engagetargets_tasks.get(d.get("key"), EmptyTaskAction) else: - t = tasks_map[_id].create_from_dict(d) + cls = tasks_map.get(_id, EmptyTaskAction) + t = cls.create_from_dict(d) t.auto = d["auto"] t.enabled = d["enabled"] @@ -765,6 +769,13 @@ def __init__(self, groupId: int, speed: float, altitude: float, lastWaypoint: Op "lastWptIndex": lastWaypoint, } + @classmethod + def create_from_dict(cls, d: TaskDict) -> "RecoveryTanker": + p = d["params"] + t = cls(p["groupId"], p["speed"], p["altitude"], p.get("lastWptIndex")) + t.params = p + return t + class OrbitAction(Task): Id = "Orbit" @@ -863,6 +874,12 @@ def __init__(self, group_id: int, groupName: str, position: Vector2 = Vector2(0, "number": number } + @classmethod + def create_from_dict(cls, d: TaskDict) -> "FACAttackGroup": + t = cls(d["params"]["groupId"], d["params"]["groupName"]) + t.params = d["params"] + return t + class FACEngageGroup(Task): Id = "FAC_EngageGroup" @@ -1150,6 +1167,8 @@ def __init__(self, alt_above: int = DEFAULT_ALT, EngageTargets.Id: EngageTargets, WWIIFollowBigFormation.Id: WWIIFollowBigFormation, CarpetBombing.Id: CarpetBombing, + RecoveryTanker.Id: RecoveryTanker, + NoTask.Id: NoTask, } @@ -2506,5 +2525,6 @@ def __init__(self, value: Values = Values.AllUnits): OptRadioUsageEngage.Key: OptRadioUsageEngage, OptRadioUsageKill.Key: OptRadioUsageKill, OptInterceptionRange.Key: OptInterceptionRange, - OptRestrictTargets.Key: OptRestrictTargets + OptRestrictTargets.Key: OptRestrictTargets, + OptJettisonEmptyTanks.Key: OptJettisonEmptyTanks, } diff --git a/tests/test_task.py b/tests/test_task.py new file mode 100644 index 00000000..ab710bf3 --- /dev/null +++ b/tests/test_task.py @@ -0,0 +1,59 @@ +import inspect +import os +import tempfile +import unittest + +import dcs +import dcs.task as task + + +def _load_task(d): + return task._create_from_dict(dict({"auto": False, "enabled": True, "number": 1}, **d)) + + +class TaskLoadTests(unittest.TestCase): + + def test_all_tasks_load_back(self) -> None: + # tasks that need constructor arguments + instances = { + task.FACAttackGroup: task.FACAttackGroup(7, "target"), + task.RecoveryTanker: task.RecoveryTanker(5, 400.0, 3000.0, 2), + } + for _, cls in inspect.getmembers(task, inspect.isclass): + if not issubclass(cls, task.Task) or cls.__module__ != "dcs.task" \ + or cls in (task.Task, task.Option, task.WrappedAction, task.EmptyTaskAction, task.EngageTargets): + continue + t = instances.get(cls) or cls() + with self.subTest(cls.__name__): + loaded = _load_task(t.dict()) + self.assertIs(type(loaded), cls) + self.assertEqual(loaded.dict(), t.dict()) + + def test_unknown_tasks_are_preserved(self) -> None: + unknown = [ + {"id": "SomeFutureTask", "params": {"x": 1}}, + {"id": "WrappedAction", "params": {"action": {"id": "Option", "params": {"name": 999, "value": 3}}}}, + {"id": "WrappedAction", "params": {"action": {"id": "FutureCommand", "params": {"v": 2}}}}, + {"id": "EngageTargets", "key": "FutureKey", "params": {"targetTypes": {}}}, + ] + for d in unknown: + with self.subTest(d["id"]): + t = _load_task(d) + self.assertIsInstance(t, task.EmptyTaskAction) + self.assertEqual(t.dict()["id"], d["id"]) + self.assertEqual(t.dict()["params"], d["params"]) + + def test_mission_with_tasks_reloads(self) -> None: + m = dcs.Mission() + group = m.flight_group_inflight(m.country("USA"), "F", dcs.planes.FA_18C_hornet, + m.terrain.airports["Batumi"].position, 5000) + tasks = [task.OptJettisonEmptyTanks(), task.RecoveryTanker(5, 400.0, 3000.0, 2), + task.FACAttackGroup(7, "target"), task.NoTask()] + group.points[0].tasks.extend(tasks) + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "tasks.miz") + m.save(path) + m2 = dcs.Mission() + self.assertEqual(m2.load_file(path), []) + loaded = m2.country("USA").plane_group[0].points[0].tasks[-len(tasks):] + self.assertEqual([type(t) for t in loaded], [type(t) for t in tasks]) From 4bafbc85f16230288c59cbb2cf722eb361f27bd1 Mon Sep 17 00:00:00 2001 From: Rene Peinthor Date: Sun, 25 Oct 2026 10:15:12 +0100 Subject: [PATCH 08/22] Fix lost goals, missing neutral countries and leftover breakpoint - Goals.dict() numbered the goals of each side from 1 within one table, so goals of different sides overwrote each other on save. - The neutrals of the coalitions table were computed from a hard coded range of country ids (0-12, 15-46), dropping Denmark and every country added since. Use all known countries plus those of the neutral coalition, minus blue and red. - Remove a debug breakpoint() from Unit.__init__ that dropped into pdb for any unit with an empty type (added in 0d395dd). --- dcs/goals.py | 5 +++-- dcs/mission.py | 8 +++++--- dcs/unit.py | 2 -- tests/test_mission.py | 44 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 7 deletions(-) diff --git a/dcs/goals.py b/dcs/goals.py index 0772f159..a7d1ea3c 100644 --- a/dcs/goals.py +++ b/dcs/goals.py @@ -76,5 +76,6 @@ def generate_result(self): return d def dict(self): - return {i + 1: self.goals[side][i].dict() - for side in ["blue", "red", "offline"] for i in range(0, len(self.goals[side]))} + # all sides share one table, so number them consecutively + all_goals = [g for side in ["blue", "red", "offline"] for g in self.goals[side]] + return {i + 1: g.dict() for i, g in enumerate(all_goals)} diff --git a/dcs/mission.py b/dcs/mission.py index e464d289..bd870d66 100644 --- a/dcs/mission.py +++ b/dcs/mission.py @@ -95,8 +95,6 @@ class Mission: Args: terrain: the used terrain for this mission. """ - _COUNTRY_IDS = {x for x in range(0, 13)} | {x for x in range(15, 47)} - _CURRENT_MIZ_VERSION: int = 20 # on save this version number will be written def __init__(self, terrain: Optional[Terrain] = None) -> None: @@ -2171,7 +2169,11 @@ def dict(self): m["coalition"][col] = self.coalition[col].dict() col_blue = {self.coalition["blue"].country(x).id for x in self.coalition["blue"].countries.keys()} col_red = {self.coalition["red"].country(x).id for x in self.coalition["red"].countries.keys()} - col_neutral = list(Mission._COUNTRY_IDS - col_blue - col_red) + col_neutral_countries = set() + if "neutrals" in self.coalition: + col_neutral_countries = {self.coalition["neutrals"].country(x).id + for x in self.coalition["neutrals"].countries.keys()} + col_neutral = sorted((set(countries.country_dict) | col_neutral_countries) - col_blue - col_red) col_blue = list(col_blue) col_red = list(col_red) m["coalitions"] = { diff --git a/dcs/unit.py b/dcs/unit.py index 3c1f8dbc..7affbf9b 100644 --- a/dcs/unit.py +++ b/dcs/unit.py @@ -36,8 +36,6 @@ def from_percentage(p): class Unit: def __init__(self, _id, terrain: Terrain, name: Optional[str] = None, type="") -> None: - if type == "": - breakpoint() self.type = type self._terrain = terrain self.position = mapping.Point(0, 0, self._terrain) diff --git a/tests/test_mission.py b/tests/test_mission.py index 8ddff939..02968cc6 100644 --- a/tests/test_mission.py +++ b/tests/test_mission.py @@ -1,7 +1,9 @@ import os import time import unittest +from unittest import mock from pathlib import Path +import tempfile import zipfile import math @@ -1541,3 +1543,45 @@ def test_update_warehouses_gap_fills_missing_entries(self): self.assertNotIn(uid, m.warehouses.warehouses) m.update_warehouses() self.assertIn(uid, m.warehouses.warehouses) + + +class SaveLoadRoundTripTests(unittest.TestCase): + + def _save_and_load(self, m: dcs.Mission) -> dcs.Mission: + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "roundtrip.miz") + m.save(path) + m2 = dcs.Mission() + self.assertEqual(m2.load_file(path), []) + return m2 + + def test_unit_with_empty_type(self) -> None: + with mock.patch("sys.breakpointhook", side_effect=AssertionError("breakpoint() called")): + static = dcs.unit.Static(1, "static", "", Caucasus()) + self.assertEqual(static.type, "") + + def test_goals_of_all_sides_are_saved(self) -> None: + m = dcs.Mission() + m.goals.add_blue(dcs.goals.Goal("blue 1")) + m.goals.add_blue(dcs.goals.Goal("blue 2")) + m.goals.add_red(dcs.goals.Goal("red 1")) + m.goals.add_offline(dcs.goals.Goal("offline 1")) + + m2 = self._save_and_load(m) + comments = {side: [g.comment for g in goals] for side, goals in m2.goals.goals.items()} + self.assertEqual(comments, {"blue": ["blue 1", "blue 2"], "red": ["red 1"], "offline": ["offline 1"]}) + + def test_all_neutral_countries_in_coalitions(self) -> None: + m = dcs.Mission() + coalitions = m.dict()["coalitions"] + neutrals = set(coalitions["neutrals"].values()) + blue = set(coalitions["blue"].values()) + red = set(coalitions["red"].values()) + self.assertFalse(neutrals & (blue | red)) + self.assertEqual(neutrals | blue | red, set(dcs.countries.country_dict)) + + m = dcs.Mission() + m.load_file("tests/missions/Draw_tool_test.miz") + algeria = m.country("Algeria") + self.assertIn(algeria, m.coalition["neutrals"].countries.values()) + self.assertIn(algeria.id, m.dict()["coalitions"]["neutrals"].values()) From a7dd712240b113a56a10ec221f9bad79d147d2d6 Mon Sep 17 00:00:00 2001 From: Rene Peinthor Date: Sun, 25 Oct 2026 10:15:18 +0100 Subject: [PATCH 09/22] Fix trigger actions that produced broken or unloadable missions - MarkToAll, MarkToCoalition and MarkToGroup emitted their runtime arguments in the wrong order (zone/text and readonly/comment swapped), so marks in re-saved missions were broken. Use the order DCS writes: value, text, zone, [coalition|group], readonly, comment. - Picture actions wrote the alignment and size unit enums (and the Coalition enum of PictureToCoalition) as bare Python reprs, which made the mission unloadable. Normalize them on construction and write their raw values. Fix the enums' __eq__ recursing forever and make them hashable again. - AddRadioItemForCoalition and RemoveRadioItemForCoalition defaulted coalitionlist to the builtin all() instead of the string "all". --- dcs/action.py | 70 ++++++++++++++++++++++++++----------------- tests/test_mission.py | 46 ++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 28 deletions(-) diff --git a/dcs/action.py b/dcs/action.py index aed30e14..8d6f43a3 100644 --- a/dcs/action.py +++ b/dcs/action.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Dict, List, Type, Optional, TYPE_CHECKING +from typing import Any, Dict, List, Type, Optional, Union, TYPE_CHECKING from dcs.lua.serialize import dumps from dcs.translation import String, ResourceKey from enum import Enum, IntEnum @@ -96,7 +96,7 @@ def dict(self): class AddRadioItemForCoalition(Action): predicate = "a_add_radio_item_for_coalition" - def __init__(self, coalitionlist=all, radiotext=String(), flag=1, value=1): + def __init__(self, coalitionlist="all", radiotext=String(), flag=1, value=1): super(AddRadioItemForCoalition, self).__init__(AddRadioItemForCoalition.predicate) self.coalitionlist = coalitionlist self.params.append(self.coalitionlist) @@ -716,15 +716,16 @@ class MarkToAll(TextAction): def __init__(self, value: int, zone: int, text: String = String(), comment: String = String(), readonly: bool = True) -> None: super().__init__(MarkToAll.predicate, text) + # parameter order of a_mark_to_all: value, text, zone, readonly, comment self.value = value self.params.append(self.value) + self.params.append(self.text) self.zone = zone self.params.append(self.zone) - self.params.append(self.text) - self.comment = comment - self.params.append(self.comment) self.readonly = readonly self.params.append(self.readonly) + self.comment = comment + self.params.append(self.comment) @classmethod def create_from_dict(cls, d: Dict[Any, Any], mission) -> "MarkToAll": @@ -748,19 +749,20 @@ def __init__(self, value: int, zone: int, text: String = String(), comment: String = String(), readonly: bool = True) -> None: super().__init__(MarkToCoalition.predicate, text) + # parameter order of a_mark_to_coalition: value, text, zone, coalitionlist, readonly, comment self.value = value self.params.append(self.value) + self.params.append(self.text) self.zone = zone self.params.append(self.zone) if not isinstance(coalitionlist, Coalition): raise TypeError("Unexpected type for coalitionlist") self.coalitionlist = coalitionlist.value self.params.append(self.coalitionlist) - self.params.append(self.text) - self.comment = comment - self.params.append(self.comment) self.readonly = readonly self.params.append(self.readonly) + self.comment = comment + self.params.append(self.comment) @classmethod def create_from_dict(cls, d: Dict[Any, Any], mission) -> "MarkToCoalition": @@ -786,17 +788,18 @@ def __init__(self, value: int, zone: int, group: int = 0, text: String = String(), comment: String = String(), readonly: bool = True) -> None: super(MarkToGroup, self).__init__(MarkToGroup.predicate, text) + # parameter order of a_mark_to_group: value, text, zone, group, readonly, comment self.value = value self.params.append(self.value) + self.params.append(self.text) self.zone = zone self.params.append(self.zone) self.group = group self.params.append(self.group) - self.params.append(self.text) - self.comment = comment - self.params.append(self.comment) self.readonly = readonly self.params.append(self.readonly) + self.comment = comment + self.params.append(self.comment) @classmethod def create_from_dict(cls, d: Dict[Any, Any], mission) -> "MarkToGroup": @@ -1058,7 +1061,7 @@ def dict(self): class RemoveRadioItemForCoalition(Action): predicate = "a_remove_radio_item_for_coalition" - def __init__(self, coalitionlist=all, radiotext=String()): + def __init__(self, coalitionlist="all", radiotext=String()): super(RemoveRadioItemForCoalition, self).__init__(RemoveRadioItemForCoalition.predicate) self.coalitionlist = coalitionlist self.params.append(self.coalitionlist) @@ -1835,7 +1838,9 @@ class HorzAlignment(Enum): def __eq__(self, other: Any) -> bool: if isinstance(other, str): return self.value == other - return self == other + return self is other + + __hash__ = Enum.__hash__ class VertAlignment(Enum): Top = "0" @@ -1845,7 +1850,9 @@ class VertAlignment(Enum): def __eq__(self, other: Any) -> bool: if isinstance(other, str): return self.value == other - return self == other + return self is other + + __hash__ = Enum.__hash__ class SizeUnits(Enum): OriginalSize = "0" @@ -1854,7 +1861,9 @@ class SizeUnits(Enum): def __eq__(self, other: Any) -> bool: if isinstance(other, str): return self.value == other - return self == other + return self is other + + __hash__ = Enum.__hash__ def __init__(self, predicate: str, file_res_key: ResourceKey, seconds: int, clearview: bool, start_delay: int, @@ -1865,9 +1874,10 @@ def __init__(self, predicate: str, file_res_key: ResourceKey, seconds: int, self.seconds = seconds self.clearview = clearview self.start_delay = start_delay - self.horz_alignment = horz_alignment - self.vert_alignment = vert_alignment - self.size_units = size_units + # accept the enums as well as their raw values as loaded from a mission + self.horz_alignment = PictureAction.HorzAlignment(horz_alignment) + self.vert_alignment = PictureAction.VertAlignment(vert_alignment) + self.size_units = PictureAction.SizeUnits(size_units) self.size = size if size not in range(1, 101): raise ValueError @@ -1878,9 +1888,9 @@ def dict(self) -> Dict[str, Any]: d["seconds"] = self.seconds d["clearview"] = self.clearview d["start_delay"] = self.start_delay - d["horzAlignment"] = self.horz_alignment - d["vertAlignment"] = self.vert_alignment - d["size_units"] = self.size_units + d["horzAlignment"] = self.horz_alignment.value + d["vertAlignment"] = self.vert_alignment.value + d["size_units"] = self.size_units.value d["size"] = self.size return d @@ -1902,7 +1912,7 @@ def __init__(self, file_res_key: ResourceKey, seconds: int, start_delay=start_delay, horz_alignment=horz_alignment, vert_alignment=vert_alignment, size=size, size_units=size_units) self.params = [self.file_res_key, seconds, clearview, start_delay, - horz_alignment, vert_alignment, size, size_units] + self.horz_alignment.value, self.vert_alignment.value, size, self.size_units.value] @classmethod def create_from_dict(cls, d: Dict[str, Any], mission: Mission) -> PictureToAll: @@ -1913,7 +1923,7 @@ def create_from_dict(cls, d: Dict[str, Any], mission: Mission) -> PictureToAll: class PictureToCoalition(PictureAction): predicate = "a_out_picture_s" - def __init__(self, coalition: Coalition, file_res_key: ResourceKey, seconds: int, + def __init__(self, coalition: Union[Coalition, str], file_res_key: ResourceKey, seconds: int, clearview: bool, start_delay: int, horz_alignment: PictureAction.HorzAlignment, vert_alignment: PictureAction.VertAlignment, size: int, size_units: PictureAction.SizeUnits) -> None: @@ -1921,9 +1931,10 @@ def __init__(self, coalition: Coalition, file_res_key: ResourceKey, seconds: int seconds=seconds, clearview=clearview, start_delay=start_delay, horz_alignment=horz_alignment, vert_alignment=vert_alignment, size=size, size_units=size_units) - self.coalition = coalition + self.coalition = coalition.value if isinstance(coalition, Coalition) else coalition self.params = [self.coalition, self.file_res_key, seconds, clearview, - start_delay, horz_alignment, vert_alignment, size, size_units] + start_delay, self.horz_alignment.value, self.vert_alignment.value, size, + self.size_units.value] @classmethod def create_from_dict(cls, d: Dict[str, Any], mission: Mission) -> PictureToCoalition: @@ -1949,7 +1960,8 @@ def __init__(self, country: Country, file_res_key: ResourceKey, seconds: int, vert_alignment=vert_alignment, size=size, size_units=size_units) self.country = country self.params = [self.country.id, self.file_res_key, seconds, clearview, - start_delay, horz_alignment, vert_alignment, size, size_units] + start_delay, self.horz_alignment.value, self.vert_alignment.value, size, + self.size_units.value] @classmethod def create_from_dict(cls, d: Dict[str, Any], mission: Mission) -> PictureToCountry: @@ -1976,7 +1988,8 @@ def __init__(self, group: Group, file_res_key: ResourceKey, seconds: int, vert_alignment=vert_alignment, size=size, size_units=size_units) self.group = group self.params = [self.group.id, self.file_res_key, seconds, clearview, - start_delay, horz_alignment, vert_alignment, size, size_units] + start_delay, self.horz_alignment.value, self.vert_alignment.value, size, + self.size_units.value] @classmethod def create_from_dict(cls, d: Dict[str, Any], mission: Mission) -> PictureToGroup: @@ -2006,7 +2019,8 @@ def __init__(self, unit_id: int, file_res_key: ResourceKey, seconds: int, vert_alignment=vert_alignment, size=size, size_units=size_units) self.unit_id = unit_id self.params = [self.unit_id, self.file_res_key, seconds, clearview, - start_delay, horz_alignment, vert_alignment, size, size_units] + start_delay, self.horz_alignment.value, self.vert_alignment.value, size, + self.size_units.value] @classmethod def create_from_dict(cls, d: Dict[str, Any], mission: Mission) -> PictureToUnit: diff --git a/tests/test_mission.py b/tests/test_mission.py index 02968cc6..fdf33514 100644 --- a/tests/test_mission.py +++ b/tests/test_mission.py @@ -1585,3 +1585,49 @@ def test_all_neutral_countries_in_coalitions(self) -> None: algeria = m.country("Algeria") self.assertIn(algeria, m.coalition["neutrals"].countries.values()) self.assertIn(algeria.id, m.dict()["coalitions"]["neutrals"].values()) + + def test_mark_actions_parameter_order(self) -> None: + with zipfile.ZipFile("tests/loadtest.miz") as miz: + orig = dcs.lua.loads(miz.read("mission").decode())["mission"]["trig"]["actions"] + m = dcs.Mission() + m.load_file("tests/loadtest.miz") + regenerated = m.triggerrules.trig()["actions"] + + def marks(actions): + return [x for a in actions.values() for x in a.split(";") if x.startswith("a_mark_to_")] + + self.assertEqual(len(marks(orig)), 3) + self.assertEqual(marks(orig), marks(regenerated)) + + def test_picture_action_from_enums(self) -> None: + P = dcs.action.PictureAction + m = dcs.Mission() + res_key = m.map_resource.add_resource_file("tests/images/blue.png") + trigger = dcs.triggers.TriggerOnce(comment="picture") + trigger.add_action(PictureToAll(res_key, 10, False, 0, P.HorzAlignment.Center, P.VertAlignment.Top, + 50, P.SizeUnits.WindowSize)) + trigger.add_action(PictureToCoalition(dcs.action.Coalition.Red, res_key, 10, False, 0, + P.HorzAlignment.Right, P.VertAlignment.Bottom, + 50, P.SizeUnits.OriginalSize)) + m.triggerrules.triggers.append(trigger) + + m2 = self._save_and_load(m) + self.assertEqual(m2.triggerrules.triggers[0].actions, trigger.actions) + self.assertEqual(m2.triggerrules.triggers[0].actions[1].coalition, "red") + + def test_picture_alignment_enums(self) -> None: + P = dcs.action.PictureAction + self.assertEqual(P.HorzAlignment.Left, P.HorzAlignment.Left) + self.assertNotEqual(P.HorzAlignment.Left, P.HorzAlignment.Right) + self.assertEqual(P.HorzAlignment.Left, "0") + self.assertEqual(len({P.HorzAlignment.Center, P.VertAlignment.Center, P.SizeUnits.WindowSize}), 3) + + def test_radio_item_for_coalition_default(self) -> None: + m = dcs.Mission() + trigger = dcs.triggers.TriggerOnce(comment="radio") + trigger.add_action(dcs.action.AddRadioItemForCoalition(radiotext=m.string("menu"), flag=5)) + trigger.add_action(dcs.action.RemoveRadioItemForCoalition(radiotext=m.string("menu"))) + m.triggerrules.triggers.append(trigger) + + m2 = self._save_and_load(m) + self.assertEqual([a.coalitionlist for a in m2.triggerrules.triggers[0].actions], ["all", "all"]) From e421d02715efd5949c58c0e2448586dfe7ada7ba Mon Sep 17 00:00:00 2001 From: Rene Peinthor Date: Sun, 25 Oct 2026 10:15:23 +0100 Subject: [PATCH 10/22] Fix halo preset handling and hang in random_distant_points Halo: - validate_halo compared against (Auto or Off), which is just Auto, so saving a mission with the Off preset raised and Off with crystals was accepted - load presets as enums, default to auto when the mission has no halo (it was written as preset "{}"), drop crystals for auto/off and keep values unknown to pydcs unchanged Rectangle.random_distant_points built the start point with x and y swapped for rectangles taller than wide, so it was outside the rectangle and the search for the end point never terminated. --- dcs/mapping.py | 6 ++++-- dcs/weather.py | 33 ++++++++++++++++++++++++--------- tests/test_mapping.py | 11 +++++++++++ tests/test_weather.py | 33 ++++++++++++++++++++++++++++++++- 4 files changed, 71 insertions(+), 12 deletions(-) diff --git a/dcs/mapping.py b/dcs/mapping.py index 073370b7..0b58dd56 100644 --- a/dcs/mapping.py +++ b/dcs/mapping.py @@ -256,7 +256,8 @@ def random_point(self) -> Point: def random_distant_points(self, distance: float) -> Tuple[Point, Point]: # determine vertical/horizontal - if self.width() > self.height(): + horizontal = self.width() > self.height() + if horizontal: axis_y = self.width() axis_x = self.height() sy = self.left @@ -272,7 +273,8 @@ def random_distant_points(self, distance: float) -> Tuple[Point, Point]: d = distance if distance < axis_y else axis_y * 0.2 sy += random.random() * (axis_y - d) sx += random.random() * axis_x - p1 = Point(sx, sy, self._terrain) + # sy runs along the long axis, which is x (north) for a vertical rectangle + p1 = Point(sx, sy, self._terrain) if horizontal else Point(sy, sx, self._terrain) while True: hdg = random.random() * 60 p2 = p1.point_from_heading(hdg_start + hdg, d) diff --git a/dcs/weather.py b/dcs/weather.py index a594bb58..ca1f8195 100644 --- a/dcs/weather.py +++ b/dcs/weather.py @@ -44,14 +44,32 @@ def __init__(self, preset: Preset = Preset.Auto, crystals: Optional[Crystals] = self.crystals = crystals def validate_halo(self) -> None: - if self.preset is (Halo.Preset.Auto or Halo.Preset.Off) and self.crystals is not None: - raise ValueError( - f"Halo preset: {self.preset} cannot have a crystals value.") - - if self.preset is not (Halo.Preset.Auto or Halo.Preset.Off) and self.crystals is None: + if self.preset in (Halo.Preset.Auto, Halo.Preset.Off): + if self.crystals is not None: + raise ValueError( + f"Halo preset: {self.preset} cannot have a crystals value.") + elif isinstance(self.preset, Halo.Preset) and self.crystals is None: raise ValueError( f"Halo preset: {self.preset} must have a crystals value.") + @classmethod + def load_from_dict(cls, d: Dict[str, Any]) -> "Halo": + # values unknown to pydcs are kept as raw strings, so they are written back unchanged + preset: Any = d.get("preset", Halo.Preset.Auto.value) + try: + preset = Halo.Preset(preset) + except ValueError: + pass + crystals: Any = d.get("crystalsPreset") + if preset in (Halo.Preset.Auto, Halo.Preset.Off): + crystals = None + elif crystals is not None: + try: + crystals = Halo.Crystals(crystals) + except ValueError: + pass + return cls(preset, crystals) + def dict(self): if self.crystals: return {"crystalsPreset": f"{self.crystals}", "preset": f"{self.preset}"} @@ -154,10 +172,7 @@ def load_from_dict(self, d): self.wind_at_ground = Wind(wind_at_ground.get("dir", 0), wind_at_ground.get("speed", 0)) self.wind_at_2000 = Wind(wind_at_2000.get("dir", 0), wind_at_2000.get("speed", 0)) self.wind_at_8000 = Wind(wind_at_8000.get("dir", 0), wind_at_8000.get("speed", 0)) - halo = d.get("halo", {}) - halo_preset = halo.get("preset", {}) - halo_crystals_preset = halo.get("crystalsPreset", {}) - self.halo = Halo(halo_preset, halo_crystals_preset) + self.halo = Halo.load_from_dict(d.get("halo", {})) self.enable_fog = d["enable_fog"] self.turbulence_at_ground = d.get("groundTurbulence", 0) season = d.get("season", {}) diff --git a/tests/test_mapping.py b/tests/test_mapping.py index 68c7e755..84900f77 100644 --- a/tests/test_mapping.py +++ b/tests/test_mapping.py @@ -76,6 +76,17 @@ def test_random_point(self) -> None: rp = r.random_point() self.assertTrue(r.point_in_rect(rp)) + def test_random_distant_points(self) -> None: + terrain = Caucasus() + wide = Rectangle(-300000, 600000, -301000, 620000, terrain) + tall = Rectangle(-300000, 600000, -320000, 601000, terrain) + for r in (wide, tall): + for i in range(0, 50): + p1, p2 = r.random_distant_points(500) + self.assertTrue(r.point_in_rect(p1)) + self.assertTrue(r.point_in_rect(p2)) + self.assertAlmostEqual(p1.distance_to_point(p2), 500) + class TriangleTests(unittest.TestCase): def test_triangle_random(self) -> None: diff --git a/tests/test_weather.py b/tests/test_weather.py index 46ea2ad9..cc74df17 100644 --- a/tests/test_weather.py +++ b/tests/test_weather.py @@ -1,7 +1,7 @@ import unittest from dcs.terrain import Caucasus -from dcs.weather import CloudPreset, Weather +from dcs.weather import CloudPreset, Halo, Weather from dcs.cloud_presets import Clouds @@ -63,3 +63,34 @@ def test_cloud_presets(self) -> None: del weather_dict["clouds"]["preset"] weather.load_from_dict(weather_dict) self.assertIsNone(weather.clouds_preset) + + +class HaloTests(unittest.TestCase): + def test_auto_and_off_need_no_crystals(self) -> None: + self.assertEqual(Halo(Halo.Preset.Off)._make_halo_dict(), {"preset": "off"}) + self.assertEqual(Halo(Halo.Preset.Auto)._make_halo_dict(), {"preset": "auto"}) + with self.assertRaises(ValueError): + Halo(Halo.Preset.Off, Halo.Crystals.AllKinds)._make_halo_dict() + + def test_other_presets_need_crystals(self) -> None: + with self.assertRaises(ValueError): + Halo(Halo.Preset.Cirrus)._make_halo_dict() + self.assertEqual(Halo(Halo.Preset.Cirrus, Halo.Crystals.Tangents)._make_halo_dict(), + {"preset": "CirrusOnly", "crystalsPreset": "Tangents"}) + + def test_load(self) -> None: + self.assertEqual(Halo.load_from_dict({})._make_halo_dict(), {"preset": "auto"}) + self.assertEqual(Halo.load_from_dict({"preset": "off"})._make_halo_dict(), {"preset": "off"}) + loaded = Halo.load_from_dict({"preset": "CirrusOnly", "crystalsPreset": "Tangents"}) + self.assertIs(loaded.preset, Halo.Preset.Cirrus) + self.assertIs(loaded.crystals, Halo.Crystals.Tangents) + # values pydcs doesn't know are written back unchanged + future = {"preset": "FuturePreset", "crystalsPreset": "FutureCrystals"} + self.assertEqual(Halo.load_from_dict(future)._make_halo_dict(), future) + + def test_weather_without_halo_round_trips(self) -> None: + weather = Weather(Caucasus()) + d = weather.dict() + del d["halo"] + weather.load_from_dict(d) + self.assertEqual(weather.dict()["halo"], {"preset": "auto"}) From 4f6e99fb8e868e0d0518a0e50fdb0af252d7e3b4 Mon Sep 17 00:00:00 2001 From: Rene Peinthor Date: Sun, 25 Oct 2026 10:27:58 +0100 Subject: [PATCH 11/22] Keep unit, group and task data on load/save Several keys written by the mission editor were not read on load and so were missing after saving a loaded mission: - vehicles: transportable, coldAtStart, AddPropVehicle and wagons (a train was saved as a bare locomotive) - ships: modulation and transportable - planes/helicopters: datalinks (Link16 setup) and dataCartridge - statics: lenghtRope (sic) - moving groups: uncontrollable and route.routeRelativeTOT - vehicle groups: route spans - ship groups: visible (visible before start) - waypoint tasks: the task name They are kept as explicit attributes that are only written when set, so newly created missions don't change. VehicleGroup.dict() no longer fails with a KeyError for groups without waypoints. tests/test_roundtrip.py loads and saves all sample missions and checks that no group, route, unit or payload keys are lost. --- dcs/flyingunit.py | 10 +++++- dcs/task.py | 7 +++- dcs/unit.py | 30 +++++++++++++++- dcs/unitgroup.py | 23 ++++++++---- tests/test_mission.py | 6 ++++ tests/test_roundtrip.py | 79 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 145 insertions(+), 10 deletions(-) create mode 100644 tests/test_roundtrip.py diff --git a/dcs/flyingunit.py b/dcs/flyingunit.py index 54402b93..f477b5e0 100644 --- a/dcs/flyingunit.py +++ b/dcs/flyingunit.py @@ -9,7 +9,7 @@ from dcs.helicopters import HelicopterType, Ka_50 import json -from typing import TYPE_CHECKING, Dict, Optional, Type +from typing import TYPE_CHECKING, Any, Dict, Optional, Type if TYPE_CHECKING: from dcs.terrain import Terrain @@ -47,6 +47,8 @@ def __init__( self.hardpoint_racks = True self.addpropaircraft = dict(_type.property_defaults) if _type.property_defaults else None self.payload_restricted: Optional[Dict[int, Dict[int, str]]] = None + self.datalinks: Optional[Dict[str, Any]] = None + self.data_cartridge: Optional[Dict[str, Any]] = None def load_from_dict(self, d): super(FlyingUnit, self).load_from_dict(d) @@ -73,6 +75,8 @@ def load_from_dict(self, d): self.hardpoint_racks = d.get("hardpoint_racks", None) self.addpropaircraft = d.get("AddPropAircraft") self.payload_restricted = d["payload"].get("restricted", None) + self.datalinks = d.get("datalinks") + self.data_cartridge = d.get("dataCartridge") return True def set_parking(self, parking_slot: ParkingSlot): @@ -234,6 +238,10 @@ def dict(self): d["Radio"] = self.radio if self.payload_restricted is not None: d["payload"]["restricted"] = self.payload_restricted + if self.datalinks is not None: + d["datalinks"] = self.datalinks + if self.data_cartridge is not None: + d["dataCartridge"] = self.data_cartridge return d diff --git a/dcs/task.py b/dcs/task.py index 972e4a3b..06cb88a5 100644 --- a/dcs/task.py +++ b/dcs/task.py @@ -37,6 +37,7 @@ def _create_from_dict(d: TaskDict) -> 'Task': t.enabled = d["enabled"] t.number = d["number"] t.params = d["params"] + t.name = d.get("name") return t @@ -57,6 +58,7 @@ def __init__(self, _id: str = ''): self.auto: bool = False self.number: int = 1 self.enabled: bool = True + self.name: Optional[str] = None # optional name given in the mission editor def __repr__(self) -> str: return str(self.dict()) @@ -68,13 +70,16 @@ def create_from_dict(cls, d: TaskDict) -> "Task": return t def dict(self) -> TaskDict: - return { + d: TaskDict = { "id": self.id, "auto": self.auto, "enabled": self.enabled, "params": self.params, "number": self.number } + if self.name is not None: + d["name"] = self.name + return d def __eq__(self, other: Any) -> bool: if not isinstance(other, Task): diff --git a/dcs/unit.py b/dcs/unit.py index 7affbf9b..b397ea40 100644 --- a/dcs/unit.py +++ b/dcs/unit.py @@ -81,14 +81,30 @@ class Vehicle(Unit): def __init__(self, terrain: Terrain, id: Optional[int] = None, name: Optional[str] = None, _type="Sandbox"): super().__init__(id, terrain, name, _type) self.player_can_drive = False + self.transportable: Optional[Dict[str, Any]] = None + self.cold_at_start: Optional[bool] = None + self.addpropvehicle: Optional[Dict[str, Any]] = None + self.wagons: Optional[Dict[int, str]] = None # train wagons def load_from_dict(self, d: Dict[str, Any]) -> None: super().load_from_dict(d) self.player_can_drive = d["playerCanDrive"] + self.transportable = d.get("transportable") + self.cold_at_start = d.get("coldAtStart") + self.addpropvehicle = d.get("AddPropVehicle") + self.wagons = d.get("wagons") def dict(self): d = super(Vehicle, self).dict() d["playerCanDrive"] = self.player_can_drive + if self.transportable is not None: + d["transportable"] = self.transportable + if self.cold_at_start is not None: + d["coldAtStart"] = self.cold_at_start + if self.addpropvehicle is not None: + d["AddPropVehicle"] = self.addpropvehicle + if self.wagons is not None: + d["wagons"] = self.wagons return d @@ -96,6 +112,8 @@ class Ship(Unit): def __init__(self, terrain: Terrain, id=None, name: Optional[str] = None, _type=None): super().__init__(id, terrain, name, _type.id) self.frequency = 127500000 + self.modulation: Optional[int] = None + self.transportable: Optional[Dict[str, Any]] = None def set_frequency(self, frequency: int) -> None: """Sets the communications frequency for this unit. @@ -108,10 +126,16 @@ def set_frequency(self, frequency: int) -> None: def load_from_dict(self, d: Dict[str, Any]) -> None: super().load_from_dict(d) self.frequency = d.get("frequency", self.frequency) + self.modulation = d.get("modulation") + self.transportable = d.get("transportable") def dict(self): d = super(Ship, self).dict() d["frequency"] = self.frequency + if self.modulation is not None: + d["modulation"] = self.modulation + if self.transportable is not None: + d["transportable"] = self.transportable return d @@ -134,6 +158,7 @@ def __init__(self, unit_id: int, name: Optional[str], _type: Union[str, Type[Uni self.can_cargo: Optional[bool] = None self.effect_preset: Optional[int] = None self.effect_transparency: Optional[float] = None + self.rope_length: Optional[float] = None if not isinstance(_type, str): if issubclass(_type, StaticType): @@ -164,6 +189,8 @@ def load_from_dict(self, d: Dict[str, Any]) -> None: self.mass = d.get("mass") self.effect_preset = d.get("effectPreset", None) self.effect_transparency = d.get("effectTransparency", None) + # sic, the mission editor writes it with this typo + self.rope_length = d.get("lenghtRope") def dict(self): d = super(Static, self).dict() @@ -174,7 +201,8 @@ def dict(self): (self.rate, "rate"), (self.mass, "mass"), (self.effect_preset, "effectPreset"), - (self.effect_transparency, "effectTransparency") + (self.effect_transparency, "effectTransparency"), + (self.rope_length, "lenghtRope"), ] for attr, keyName in fields: if attr is not None: diff --git a/dcs/unitgroup.py b/dcs/unitgroup.py index 1be6f08e..ebaceea1 100644 --- a/dcs/unitgroup.py +++ b/dcs/unitgroup.py @@ -247,9 +247,13 @@ def __init__(self, _id, name=None, start_time=0): self.task_selected = True self.spawn_probability = 1.0 self.late_activation = False + self.uncontrollable: Optional[bool] = None + self.route_relative_tot: Optional[bool] = None def load_from_dict(self, d: Dict[str, Any], terrain: Terrain) -> None: super().load_from_dict(d, terrain) + self.uncontrollable = d.get("uncontrollable") + self.route_relative_tot = d.get("route", {}).get("routeRelativeTOT") self.frequency = d.get("frequency") self.task = d.get("task") # ships don't have a task self.spawn_probability = d.get("probability", 1.0) @@ -283,6 +287,10 @@ def dict(self): d["probability"] = self.spawn_probability if self.late_activation: d["lateActivation"] = self.late_activation + if self.uncontrollable is not None: + d["uncontrollable"] = self.uncontrollable + if self.route_relative_tot is not None and "route" in d: + d["route"]["routeRelativeTOT"] = self.route_relative_tot return d @@ -303,6 +311,8 @@ def load_from_dict(self, d: Dict[str, Any], terrain: Terrain) -> None: self.communication = d.get("communication", False) self.visible = d.get("visible", False) self.manualHeading = d.get("manualHeading", False) + spans = d.get("route", {}).get("spans", {}) + self.spans = [spans[k] for k in sorted(spans)] def add_span(self, position: mapping.Point): self.spans.append({"x": position.x, "y": position.y}) @@ -326,13 +336,8 @@ def dict(self): d["frequency"] = self.frequency d["visible"] = self.visible d["manualHeading"] = self.manualHeading - if self.spans is not None: - # spans - d["route"]["spans"] = {} - i = 1 - for spawn in self.spans: - d["route"]["spans"][i] = spawn - i += 1 + if self.spans is not None and "route" in d: + d["route"]["spans"] = {i + 1: span for i, span in enumerate(self.spans)} return d @@ -577,6 +582,10 @@ def __init__(self, _id, name=None, start_time=0): self.spans = None self.visible = False # visible before start flag + def load_from_dict(self, d: Dict[str, Any], terrain: Terrain) -> None: + super().load_from_dict(d, terrain) + self.visible = d.get("visible", False) + def add_waypoint(self, position: mapping.Point, speed=20) -> MovingPoint: mp = MovingPoint(position) mp.type = "Turning Point" diff --git a/tests/test_mission.py b/tests/test_mission.py index fdf33514..b5bab685 100644 --- a/tests/test_mission.py +++ b/tests/test_mission.py @@ -1631,3 +1631,9 @@ def test_radio_item_for_coalition_default(self) -> None: m2 = self._save_and_load(m) self.assertEqual([a.coalitionlist for a in m2.triggerrules.triggers[0].actions], ["all", "all"]) + + def test_vehicle_group_without_waypoints(self) -> None: + m = dcs.Mission() + group = dcs.unitgroup.VehicleGroup(m.next_group_id(), "no waypoints") + group.add_unit(m.vehicle("unit", dcs.vehicles.Armor.M_1_Abrams)) + self.assertNotIn("route", group.dict()) diff --git a/tests/test_roundtrip.py b/tests/test_roundtrip.py new file mode 100644 index 00000000..f175df2d --- /dev/null +++ b/tests/test_roundtrip.py @@ -0,0 +1,79 @@ +"""Load and save every sample mission and check that nothing the mission editor wrote gets lost.""" +import glob +import os +import tempfile +import unittest +import zipfile +from typing import Any, Dict, Iterator, Tuple + +import dcs +from dcs.lua import loads + +GROUP_CATEGORIES = ("vehicle", "ship", "plane", "helicopter", "static") + +# keys dropped on purpose because they hold the value DCS uses when the key is absent +DEFAULT_VALUED_GROUP_KEYS = {"lateActivation": False, "probability": 1} + + +def _read_miz(path: str) -> Dict[str, Any]: + with zipfile.ZipFile(path) as miz: + return {name: loads(miz.read(name).decode("utf-8")) for name in ("mission", "options")} + + +def _groups(mission: Dict[str, Any]) -> Iterator[Tuple[str, Dict[str, Any]]]: + for side in mission["coalition"].values(): + for country in side.get("country", {}).values(): + for category in GROUP_CATEGORIES: + for group in country.get(category, {}).get("group", {}).values(): + yield category, group + + +class RoundTripTests(unittest.TestCase): + missions: Dict[str, Tuple[Dict[str, Any], Dict[str, Any]]] = {} + + @classmethod + def setUpClass(cls) -> None: + with tempfile.TemporaryDirectory() as tmp: + for path in sorted(glob.glob("tests/**/*.miz", recursive=True)): + m = dcs.Mission() + m.load_file(path) + saved = os.path.join(tmp, "saved.miz") + m.save(saved) + cls.missions[os.path.basename(path)] = (_read_miz(path), _read_miz(saved)) + + def test_group_and_unit_keys_are_kept(self) -> None: + for name, (orig, saved) in self.missions.items(): + saved_groups = {(c, g["groupId"]): g for c, g in _groups(saved["mission"]["mission"])} + for category, group in _groups(orig["mission"]["mission"]): + with self.subTest(mission=name, group=group["name"]): + new = saved_groups[(category, group["groupId"])] + lost = {k for k in set(group) - set(new) if DEFAULT_VALUED_GROUP_KEYS.get(k, object()) != group[k]} + self.assertFalse(lost, "group keys lost") + self.assertFalse(set(group.get("route", {})) - set(new.get("route", {})), "route keys lost") + new_units = {u["unitId"]: u for u in new.get("units", {}).values()} + for unit in group.get("units", {}).values(): + new_unit = new_units[unit["unitId"]] + self.assertFalse(set(unit) - set(new_unit), "unit keys lost") + self.assertFalse(set(unit.get("payload", {})) - set(new_unit.get("payload", {})), + "payload keys lost") + + def test_group_values_are_kept(self) -> None: + def facts(mission: Dict[str, Any]) -> Dict[Any, Any]: + out: Dict[Any, Any] = {} + for category, group in _groups(mission): + key = (category, group["groupId"]) + route = group.get("route", {}) + out[key + ("spans",)] = route.get("spans", {}) + out[key + ("visible",)] = group.get("visible") + out[key + ("task names",)] = [ + task.get("name") + for point in route.get("points", {}).values() + for task in point.get("task", {}).get("params", {}).get("tasks", {}).values() + ] + return out + + for name, (orig, saved) in self.missions.items(): + with self.subTest(mission=name): + before, after = facts(orig["mission"]["mission"]), facts(saved["mission"]["mission"]) + self.assertEqual({k: v for k, v in before.items() if v is not None}, + {k: v for k, v in after.items() if k in before and before[k] is not None}) From 62e89b28fac514b8180486a703d95020b38f58d6 Mon Sep 17 00:00:00 2001 From: Rene Peinthor Date: Sun, 25 Oct 2026 10:30:56 +0100 Subject: [PATCH 12/22] Keep options, forced options, neutral task and nav points on load/save - options: playerName was always replaced by "PyDCS", and difficulty options pydcs doesn't know (wakeTurbulence, autoTrimmer, cockpitLanguage, ...) were dropped - forcedOptions: options pydcs doesn't know (e.g. tips) were dropped - descriptionNeutralsTask was neither read nor written; it is now kept when present and can be set with set_description_neutralstask_text() - coalition nav_points were always written as an empty table Unknown difficulty and forced options are written back unchanged, the known ones are still managed by their attributes. Newly created missions are unchanged. --- dcs/coalition.py | 9 ++++++--- dcs/forcedoptions.py | 15 +++++++++++++-- dcs/mission.py | 33 +++++++++++++++++++++++++++++++++ tests/test_mission.py | 19 +++++++++++++++++++ tests/test_roundtrip.py | 20 ++++++++++++++++++++ 5 files changed, 91 insertions(+), 5 deletions(-) diff --git a/dcs/coalition.py b/dcs/coalition.py index f73854ed..233e26d2 100644 --- a/dcs/coalition.py +++ b/dcs/coalition.py @@ -1,5 +1,5 @@ import sys -from typing import Dict, Union, List, TYPE_CHECKING, Optional +from typing import Any, Dict, Union, List, TYPE_CHECKING, Optional import dcs.countries as countries from dcs.mapping import Point import dcs.unitgroup as unitgroup @@ -22,7 +22,8 @@ def __init__(self, name, bullseye=None): self.name = name self.countries: Dict[str, Country] = {} self.bullseye = bullseye - self.nav_points = [] # TODO + # reference points of the coalition, kept as loaded from the mission + self.nav_points: List[Dict[str, Any]] = [] @staticmethod def _sort_keys(points): @@ -92,6 +93,8 @@ def get_name(mission: "Mission", name: str) -> str: def load_from_dict(self, mission, d, countries_in_coalition: Dict[int, int]) -> List[StatusMessage]: status: List[StatusMessage] = [] + nav_points = d.get("nav_points", {}) + self.nav_points = [nav_points[k] for k in sorted(nav_points)] for country_idx in d["country"]: imp_country = d["country"][country_idx] _country = countries.get_by_id(imp_country["id"]) @@ -314,5 +317,5 @@ def dict(self): for country in sorted(self.countries.keys()): d["country"][i] = self.country(country).dict() i += 1 - d["nav_points"] = {} + d["nav_points"] = {i + 1: point for i, point in enumerate(self.nav_points)} return d diff --git a/dcs/forcedoptions.py b/dcs/forcedoptions.py index 5f5aae3e..6c034192 100644 --- a/dcs/forcedoptions.py +++ b/dcs/forcedoptions.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import Optional +from typing import Any, Dict, Optional class ForcedOptions: @@ -29,6 +29,14 @@ class Labels(Enum): DotOnly = 3 NeutralDot = 4 + # keys handled by the attributes below + _KNOWN_KEYS = { + "accidental_failures", "birds", "civTraffic", "cockpitStatusBarAllowed", "cockpitVisualRM", + "easyCommunication", "easyFlight", "easyRadar", "externalViews", "fuel", "geffect", "immortal", "labels", + "miniHUD", "optionsView", "padlock", "permitCrash", "radio", "RBDAI", "unrestrictedSATNAV", "userMarks", + "wakeTurbulence", "weapons", + } + def __init__(self): # In the order they appear in the ME. self.permit_crash: Optional[bool] = None @@ -54,8 +62,11 @@ def __init__(self): self.birds: Optional[int] = None self.cockpit_status_bar: Optional[bool] = None self.battle_damage_assessment: Optional[bool] = None + # forced options pydcs doesn't know, written back unchanged + self.unknown_options: Dict[str, Any] = {} def load_from_dict(self, d): + self.unknown_options = {k: v for k, v in d.items() if k not in ForcedOptions._KNOWN_KEYS} self.fuel = d.get("fuel") self.easy_radar = d.get("easyRadar") self.mini_hud = d.get("miniHUD") @@ -85,7 +96,7 @@ def load_from_dict(self, d): self.user_marks = d.get("userMarks") def dict(self): - d = {} + d = dict(self.unknown_options) if self.fuel is not None: d["fuel"] = self.fuel if self.easy_radar is not None: diff --git a/dcs/mission.py b/dcs/mission.py index bd870d66..08f90751 100644 --- a/dcs/mission.py +++ b/dcs/mission.py @@ -113,6 +113,8 @@ def __init__(self, terrain: Optional[Terrain] = None) -> None: self._description_text = self.string("") self._description_bluetask = self.string("") self._description_redtask = self.string("") + # only present in missions saved by newer versions of the mission editor + self._description_neutralstask: Optional[String] = None self._sortie = self.string("") self.pictureFileNameR: List[Union[ResourceKey, str]] = [] self.pictureFileNameB: List[Union[ResourceKey, str]] = [] @@ -315,6 +317,10 @@ def loaddict(fname: str, mizfile: zipfile.ZipFile, reserved_files: List[str]) -> self._description_text = self.translation.get_string(imp_mission["descriptionText"]) self._description_bluetask = self.translation.get_string(imp_mission["descriptionBlueTask"]) self._description_redtask = self.translation.get_string(imp_mission["descriptionRedTask"]) + if "descriptionNeutralsTask" in imp_mission: + self._description_neutralstask = self.translation.get_string(imp_mission["descriptionNeutralsTask"]) + else: + self._description_neutralstask = None self._sortie = self.translation.get_string(imp_mission["sortie"]) for pic in sorted(imp_mission["pictureFileNameR"]): self.pictureFileNameR.append(imp_mission["pictureFileNameR"][pic]) @@ -468,6 +474,25 @@ def set_description_redtask_text(self, text: str): """ self._description_redtask.set(text) + def description_neutralstask_text(self) -> str: + """Returns the neutral task description text. + + Returns: + the neutral task description text + """ + return str(self._description_neutralstask) if self._description_neutralstask is not None else "" + + def set_description_neutralstask_text(self, text: str): + """Sets the neutral coalitions task description text. + + Args: + text: text to set. + """ + if self._description_neutralstask is None: + self._description_neutralstask = self.string(text) + else: + self._description_neutralstask.set(text) + def add_picture_red(self, filepath: str) -> ResourceKey: """Adds a new briefing picture to the red coalition. @@ -2160,6 +2185,8 @@ def dict(self): m["pictureFileNameN"][i + 1] = str(self.pictureFileNameN[i]) m["descriptionBlueTask"] = self._description_bluetask.id m["descriptionRedTask"] = self._description_redtask.id + if self._description_neutralstask is not None: + m["descriptionNeutralsTask"] = self._description_neutralstask.id if self.init_script_file is not None: m["initScriptFile"] = self.init_script_file if self.init_script is not None: @@ -2337,8 +2364,12 @@ def __init__(self): self.weapons = False self.setGlobal = True self.labels = False + # options pydcs doesn't know, written back unchanged + self.unknown_options: Dict[str, Any] = {} def load_from_dict(self, d): + known = self.dict() + self.unknown_options = {k: v for k, v in d.items() if k not in known} self.geffect = d.get("geffect", "realistic") self.padlock = d.get("padlock", False) self.compassTape = d.get("compassTape", True) @@ -2374,6 +2405,7 @@ def load_from_dict(self, d): def dict(self): return { + **self.unknown_options, "geffect": self.geffect, "padlock": self.padlock, "compassTape": self.compassTape, @@ -2419,6 +2451,7 @@ def __init__(self): self.options = {} def load_from_dict(self, d): + self.playerName = d.get("playerName", self.playerName) self.difficulty.load_from_dict(d["difficulty"]) self.options = d diff --git a/tests/test_mission.py b/tests/test_mission.py index b5bab685..06f0ff92 100644 --- a/tests/test_mission.py +++ b/tests/test_mission.py @@ -1637,3 +1637,22 @@ def test_vehicle_group_without_waypoints(self) -> None: group = dcs.unitgroup.VehicleGroup(m.next_group_id(), "no waypoints") group.add_unit(m.vehicle("unit", dcs.vehicles.Armor.M_1_Abrams)) self.assertNotIn("route", group.dict()) + + def test_coalition_nav_points(self) -> None: + m = dcs.Mission() + m.load_file("tests/loadtest.miz") + point = {"type": "Default", "comment": "", "callsignStr": "REF1", "id": 1, "properties": {"vnav": 1}, + "x": -280000, "y": 650000} + m.coalition["blue"].nav_points.append(point) + + m2 = self._save_and_load(m) + self.assertEqual(m2.coalition["blue"].nav_points, [point]) + self.assertEqual(m2.coalition["red"].nav_points, []) + + def test_neutral_task_description(self) -> None: + m = dcs.Mission() + self.assertNotIn("descriptionNeutralsTask", m.dict()) + m.set_description_neutralstask_text("neutral task") + + m2 = self._save_and_load(m) + self.assertEqual(m2.description_neutralstask_text(), "neutral task") diff --git a/tests/test_roundtrip.py b/tests/test_roundtrip.py index f175df2d..dc61f988 100644 --- a/tests/test_roundtrip.py +++ b/tests/test_roundtrip.py @@ -77,3 +77,23 @@ def facts(mission: Dict[str, Any]) -> Dict[Any, Any]: before, after = facts(orig["mission"]["mission"]), facts(saved["mission"]["mission"]) self.assertEqual({k: v for k, v in before.items() if v is not None}, {k: v for k, v in after.items() if k in before and before[k] is not None}) + + def test_options_are_kept(self) -> None: + for name, (orig, saved) in self.missions.items(): + with self.subTest(mission=name): + before, after = orig["options"]["options"], saved["options"]["options"] + self.assertEqual(before.get("playerName"), after.get("playerName")) + self.assertFalse(set(before) - set(after), "options lost") + self.assertFalse(set(before["difficulty"]) - set(after["difficulty"]), "difficulty options lost") + for key, value in before["difficulty"].items(): + self.assertEqual(value, after["difficulty"][key], key) + + def test_mission_settings_are_kept(self) -> None: + for name, (orig, saved) in self.missions.items(): + with self.subTest(mission=name): + before, after = orig["mission"]["mission"], saved["mission"]["mission"] + self.assertEqual(before.get("forcedOptions"), after.get("forcedOptions")) + self.assertEqual("descriptionNeutralsTask" in before, "descriptionNeutralsTask" in after) + for side in before["coalition"]: + self.assertEqual(before["coalition"][side].get("nav_points", {}), + after["coalition"][side]["nav_points"]) From 12d2dd3741eebdf2ac9e33a4e694b33f99cc0369 Mon Sep 17 00:00:00 2001 From: Rene Peinthor Date: Sun, 25 Oct 2026 10:38:20 +0100 Subject: [PATCH 13/22] Keep trigger data on load/save - MessageToAll (a_out_text_delay) lost its start_delay. It is now kept when present; it stays optional because older mission editor versions don't write it, so their missions still get the same trigger code. - Radio item and mark actions didn't write KeyDict_radiotext and KeyDict_comment next to their localized fields, as TextAction already does for KeyDict_text. - The trigger colour (colorItem) was not loaded, every loaded trigger became white. - Quad point trigger zones were always saved with radius 0, a radius stored in the loaded mission is now kept. The round-trip tests also check that pydcs regenerates the same trigger code as the mission editor for all sample missions. --- dcs/action.py | 19 +++++++++++++++++-- dcs/triggers.py | 12 +++++++----- tests/test_roundtrip.py | 38 ++++++++++++++++++++++++++++++++++++++ tests/test_triggers.py | 10 +++++++++- 4 files changed, 71 insertions(+), 8 deletions(-) diff --git a/dcs/action.py b/dcs/action.py index 8d6f43a3..0b034426 100644 --- a/dcs/action.py +++ b/dcs/action.py @@ -88,6 +88,7 @@ def create_from_dict(cls, d, mission): def dict(self): d = super(AddRadioItem, self).dict() d["radiotext"] = self.radiotext.id + d["KeyDict_radiotext"] = self.radiotext.id d["flag"] = self.flag d["value"] = self.value return d @@ -115,6 +116,7 @@ def dict(self): d = super(AddRadioItemForCoalition, self).dict() d["coalitionlist"] = self.coalitionlist d["radiotext"] = self.radiotext.id + d["KeyDict_radiotext"] = self.radiotext.id d["flag"] = self.flag d["value"] = self.value return d @@ -142,6 +144,7 @@ def dict(self): d = super(AddRadioItemForGroup, self).dict() d["group"] = self.group d["radiotext"] = self.radiotext.id + d["KeyDict_radiotext"] = self.radiotext.id d["flag"] = self.flag d["value"] = self.value return d @@ -737,6 +740,7 @@ def dict(self) -> Dict[Any, Any]: d["value"] = self.value d["zone"] = self.zone d["comment"] = self.comment.id + d["KeyDict_comment"] = self.comment.id d["readonly"] = self.readonly return d @@ -777,6 +781,7 @@ def dict(self): d["zone"] = self.zone d["coalitionlist"] = self.coalitionlist d["comment"] = self.comment.id + d["KeyDict_comment"] = self.comment.id d["readonly"] = self.readonly return d @@ -812,6 +817,7 @@ def dict(self) -> Dict[Any, Any]: d["zone"] = self.zone d["group"] = self.group d["comment"] = self.comment.id + d["KeyDict_comment"] = self.comment.id d["readonly"] = self.readonly return d @@ -820,23 +826,29 @@ class MessageToAll(TextAction): predicate = "a_out_text_delay" def __init__(self, text: String = String(), seconds: int = 10, - clearview: bool = False) -> None: + clearview: bool = False, start_delay: Optional[int] = None) -> None: super().__init__(MessageToAll.predicate, text) self.params.append(self.text) self.seconds = seconds self.params.append(self.seconds) self.clearview = clearview self.params.append(self.clearview) + # older versions of the mission editor don't write a start delay + self.start_delay = start_delay + if self.start_delay is not None: + self.params.append(self.start_delay) @classmethod def create_from_dict(cls, d: Dict[Any, Any], mission) -> "MessageToAll": return cls(mission.translation.get_string(d["text"]), d["seconds"], - d["clearview"]) + d["clearview"], d.get("start_delay")) def dict(self) -> Dict[Any, Any]: d = super().dict() d["seconds"] = self.seconds d["clearview"] = self.clearview + if self.start_delay is not None: + d["start_delay"] = self.start_delay return d @@ -1055,6 +1067,7 @@ def create_from_dict(cls, d, mission): def dict(self): d = super(RemoveRadioItem, self).dict() d["radiotext"] = self.radiotext.id + d["KeyDict_radiotext"] = self.radiotext.id return d @@ -1076,6 +1089,7 @@ def dict(self): d = super(RemoveRadioItemForCoalition, self).dict() d["coalitionlist"] = self.coalitionlist d["radiotext"] = self.radiotext.id + d["KeyDict_radiotext"] = self.radiotext.id return d @@ -1097,6 +1111,7 @@ def dict(self): d = super(RemoveRadioItemForGroup, self).dict() d["group"] = self.group d["radiotext"] = self.radiotext.id + d["KeyDict_radiotext"] = self.radiotext.id return d diff --git a/dcs/triggers.py b/dcs/triggers.py index 78af817f..cfcd24e4 100644 --- a/dcs/triggers.py +++ b/dcs/triggers.py @@ -79,13 +79,13 @@ def __repr__(self): class TriggerZoneQuadPoint(TriggerZone): def __init__(self, _id, position: mapping.Point, verticies: List[mapping.Point], hidden=False, name="", color=None, properties=None, - heading: Optional[float] = None, link_unit_id: Optional[int] = None): - # Forward by keyword and pass radius=0: quad zones have no radius (the DCS ME - # writes "radius": 0 for them). Positional forwarding silently slots `heading` - # into the parent's `radius` arg (added in PR #254) -- keyword args prevent that. + heading: Optional[float] = None, link_unit_id: Optional[int] = None, radius: float = 0): + # Forward by keyword: positional forwarding silently slots `heading` into the parent's + # `radius` arg (added in PR #254) -- keyword args prevent that. Quad zones have no radius + # and the DCS ME writes 0 for new ones, but a radius stored in a loaded mission is kept. super(TriggerZoneQuadPoint, self).__init__( _id, position, hidden=hidden, name=name, color=color, - properties=properties, radius=0, heading=heading, link_unit_id=link_unit_id) + properties=properties, radius=radius, heading=heading, link_unit_id=link_unit_id) self.type = TriggerZoneType.QuadPoint self.verticies = copy.copy(verticies) @@ -148,6 +148,7 @@ def _make_quad(self, imp_zone) -> TriggerZoneQuadPoint: imp_zone.get("properties", {}), imp_zone.get("heading", None), imp_zone.get("linkUnit", None), + radius=imp_zone.get("radius", 0), ) return tz @@ -236,6 +237,7 @@ def __init__(self, event: Event, comment: str): @classmethod def create_from_dict(cls, mission, d) -> 'TriggerRule': trig = cls(Event(d["eventlist"]), d["comment"]) + trig.color = d.get("colorItem", TriggerColor.White) actions = d["actions"] for a in actions: action_ = action.actions_map[actions[a]["predicate"]].create_from_dict(actions[a], mission) diff --git a/tests/test_roundtrip.py b/tests/test_roundtrip.py index dc61f988..b629726f 100644 --- a/tests/test_roundtrip.py +++ b/tests/test_roundtrip.py @@ -30,6 +30,7 @@ def _groups(mission: Dict[str, Any]) -> Iterator[Tuple[str, Dict[str, Any]]]: class RoundTripTests(unittest.TestCase): missions: Dict[str, Tuple[Dict[str, Any], Dict[str, Any]]] = {} + generated_trig_actions: Dict[str, Dict[int, str]] = {} @classmethod def setUpClass(cls) -> None: @@ -37,6 +38,7 @@ def setUpClass(cls) -> None: for path in sorted(glob.glob("tests/**/*.miz", recursive=True)): m = dcs.Mission() m.load_file(path) + cls.generated_trig_actions[os.path.basename(path)] = m.triggerrules.trig()["actions"] saved = os.path.join(tmp, "saved.miz") m.save(saved) cls.missions[os.path.basename(path)] = (_read_miz(path), _read_miz(saved)) @@ -97,3 +99,39 @@ def test_mission_settings_are_kept(self) -> None: for side in before["coalition"]: self.assertEqual(before["coalition"][side].get("nav_points", {}), after["coalition"][side]["nav_points"]) + + def test_trigger_action_keys_are_kept(self) -> None: + for name, (orig, saved) in self.missions.items(): + with self.subTest(mission=name): + before, after = orig["mission"]["mission"]["trigrules"], saved["mission"]["mission"]["trigrules"] + for rule_key, rule in before.items(): + for action_key, action in rule["actions"].items(): + new_action = after[rule_key]["actions"][action_key] + expected = [] + if action["predicate"] == "a_out_text_delay" and "start_delay" in action: + expected.append("start_delay") + # KeyDict_ belongs to localized fields; on other fields it's a stale leftover + expected += [k for k in ("KeyDict_radiotext", "KeyDict_comment") + if k in action and k[len("KeyDict_"):] in new_action] + for key in expected: + self.assertEqual(action[key], new_action.get(key), (action["predicate"], key)) + + def test_trigger_zones_are_kept(self) -> None: + for name, (orig, saved) in self.missions.items(): + with self.subTest(mission=name): + before = orig["mission"]["mission"]["triggers"]["zones"] + after = {z["zoneId"]: z for z in saved["mission"]["mission"]["triggers"]["zones"].values()} + for zone in before.values(): + self.assertEqual(zone["radius"], after[zone["zoneId"]]["radius"], zone["name"]) + + def test_generated_trigger_code_matches_mission_editor(self) -> None: + for name, (orig_miz, _) in self.missions.items(): + with self.subTest(mission=name): + orig = orig_miz["mission"]["mission"]["trig"]["actions"] + generated = self.generated_trig_actions[name] + for key, code in orig.items(): + for dcs_call, pydcs_call in zip(code.split(";"), generated[key].split(";")): + dcs_call, pydcs_call = dcs_call.strip(), pydcs_call.strip() + # the mission editor fills in a trailing default argument that isn't stored + if dcs_call != pydcs_call: + self.assertEqual(dcs_call, pydcs_call[:-1] + ", 0)") diff --git a/tests/test_triggers.py b/tests/test_triggers.py index cdbfce70..6b7c21f6 100644 --- a/tests/test_triggers.py +++ b/tests/test_triggers.py @@ -1,7 +1,7 @@ import unittest from dcs.mission import Mission -from dcs.triggers import Rules +from dcs.triggers import Rules, TriggerOnce from dcs import lua @@ -5807,3 +5807,11 @@ def test_load_trig_func_startup(self): lua.dumps(action), lua.dumps(r.trig()['funcStartup'][key]), f"FuncStartup nr {key}") + + def test_load_trigger_color(self): + m = Mission() + d = {"eventlist": "", "comment": "colored", "predicate": "triggerOnce", "colorItem": "0x0080ffff", + "actions": {}, "rules": {}} + trigger = TriggerOnce.create_from_dict(m, d) + self.assertEqual(trigger.dict()["colorItem"], "0x0080ffff") + From 00fa9ea1cf645eb9c5fa3b7ab9b8490e2ad18dcf Mon Sep 17 00:00:00 2001 From: Rene Peinthor Date: Sun, 25 Oct 2026 10:41:42 +0100 Subject: [PATCH 14/22] Keep weather and drawing data on load/save Weather: - fog density was not loaded and so dropped - keys pydcs doesn't know (modifiedTime, localized preset names, older turbulence and season.iseason values) are written back unchanged - with a cloud preset, thickness and density were always written as 200 and 0; the mission editor can keep other values there, so the loaded ones are written back (new missions still get 200 and 0) - cyclones were renumbered in the order they appear in the file instead of by their index Drawings: hiddenOnPlanner was not loaded and so dropped. New missions are unchanged. --- dcs/drawing/drawing.py | 7 ++++++- dcs/drawing/layer.py | 1 + dcs/weather.py | 28 +++++++++++++++++++++------- tests/test_roundtrip.py | 27 +++++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 8 deletions(-) diff --git a/dcs/drawing/drawing.py b/dcs/drawing/drawing.py index 4a983f20..c3c5a350 100644 --- a/dcs/drawing/drawing.py +++ b/dcs/drawing/drawing.py @@ -1,5 +1,6 @@ from enum import Enum -from dataclasses import dataclass +from dataclasses import dataclass, field +from typing import Optional import dcs.mapping as mapping @@ -47,10 +48,14 @@ class Drawing: name: str color: Rgba layer_name: str + # keyword only, so the subclasses can still add positional fields + hidden_on_planner: Optional[bool] = field(default=None, kw_only=True) def dict(self): d = {} d["visible"] = self.visible + if self.hidden_on_planner is not None: + d["hiddenOnPlanner"] = self.hidden_on_planner d["mapX"] = self.position.x d["mapY"] = self.position.y d["name"] = self.name diff --git a/dcs/drawing/layer.py b/dcs/drawing/layer.py index 59f60219..8caa53c8 100644 --- a/dcs/drawing/layer.py +++ b/dcs/drawing/layer.py @@ -31,6 +31,7 @@ def load_from_dict(self, data): for object_index in sorted(data["objects"].keys()): object_data = data["objects"][object_index] object = self.load_drawing_from_data(object_data) + object.hidden_on_planner = object_data.get("hiddenOnPlanner") self.objects.append(object) def dict(self): diff --git a/dcs/weather.py b/dcs/weather.py index ca1f8195..e226958f 100644 --- a/dcs/weather.py +++ b/dcs/weather.py @@ -153,6 +153,7 @@ def __init__(self, terrain): self.name = "Summer, clean sky" self.fog_thickness = 0 self.fog_visibility = 25 + self.fog_density: Optional[int] = None self.visibility_distance = 80000 self.clouds_preset: Optional[CloudPreset] = None self.clouds_thickness = 200 @@ -163,7 +164,16 @@ def __init__(self, terrain): self.enable_dust = False self.dust_density = 0 + # values pydcs doesn't know, written back unchanged + self.unknown_values: Dict[str, Any] = {} + self.unknown_season_values: Dict[str, Any] = {} + + _KNOWN_KEYS = {"atmosphere_type", "wind", "enable_fog", "halo", "groundTurbulence", "season", "type_weather", + "qnh", "cyclones", "name", "fog", "visibility", "clouds", "enable_dust", "dust_density"} + def load_from_dict(self, d): + self.unknown_values = {k: v for k, v in d.items() if k not in Weather._KNOWN_KEYS} + self.unknown_season_values = {k: v for k, v in d.get("season", {}).items() if k != "temperature"} self.atmosphere_type = d["atmosphere_type"] wind = d.get("wind", {}) wind_at_ground = wind.get("atGround", {}) @@ -180,7 +190,7 @@ def load_from_dict(self, d): self.type_weather = d.get("type_weather", 0) self.qnh = d.get("qnh", 760) cyclones = d.get("cyclones", {}) - for x in cyclones: + for x in sorted(cyclones): c = Cyclone() c.centerX = cyclones[x].get("centerX", 0) c.centerZ = cyclones[x].get("centerZ", 0) @@ -193,6 +203,7 @@ def load_from_dict(self, d): fog = d.get("fog", {}) self.fog_thickness = fog.get("thickness", 0) self.fog_visibility = fog.get("visibility", 25) + self.fog_density = fog.get("density") visibility = d.get("visibility", {}) self.visibility_distance = visibility.get("distance", 80000) clouds = d.get("clouds", {}) @@ -383,18 +394,19 @@ def _make_cloud_dict(self) -> Dict[str, Any]: self.clouds_preset.validate_base(self.clouds_base) - # The hard coded values here seem to be the case for all presets. They are not - # configurable in the ME and not defined by clouds.lua. + # Thickness and density are not configurable in the ME for presets and not defined by + # clouds.lua. New missions use 200 and 0, but the ME may keep other values from before a + # preset was selected, so the loaded ones are written back. return { "base": self.clouds_base, - "density": 0, + "density": self.clouds_density, "preset": self.clouds_preset.name, - "thickness": 200, + "thickness": self.clouds_thickness, "iprecptns": 0, } def dict(self) -> Dict[str, Any]: - d = {} + d = dict(self.unknown_values) d["atmosphere_type"] = self.atmosphere_type d["wind"] = {"atGround": self.wind_at_ground.dict(), "at2000": self.wind_at_2000.dict(), @@ -402,13 +414,15 @@ def dict(self) -> Dict[str, Any]: d["enable_fog"] = self.enable_fog d["halo"] = self.halo._make_halo_dict() d["groundTurbulence"] = self.turbulence_at_ground - d["season"] = {"temperature": self.season_temperature} + d["season"] = {**self.unknown_season_values, "temperature": self.season_temperature} assert isinstance(self.type_weather, int) d["type_weather"] = self.type_weather d["qnh"] = self.qnh d["cyclones"] = {x + 1: self.cyclones[x].dict() for x in range(0, len(self.cyclones))} d["name"] = self.name d["fog"] = {"thickness": self.fog_thickness, "visibility": self.fog_visibility} + if self.fog_density is not None: + d["fog"]["density"] = self.fog_density d["visibility"] = {"distance": self.visibility_distance} d["clouds"] = self._make_cloud_dict() d["enable_dust"] = self.enable_dust diff --git a/tests/test_roundtrip.py b/tests/test_roundtrip.py index b629726f..36a9f166 100644 --- a/tests/test_roundtrip.py +++ b/tests/test_roundtrip.py @@ -135,3 +135,30 @@ def test_generated_trigger_code_matches_mission_editor(self) -> None: # the mission editor fills in a trailing default argument that isn't stored if dcs_call != pydcs_call: self.assertEqual(dcs_call, pydcs_call[:-1] + ", 0)") + + def test_weather_is_kept(self) -> None: + def flatten(d: Dict[Any, Any], prefix: str = "") -> Dict[str, Any]: + out: Dict[str, Any] = {} + for k, v in d.items(): + if isinstance(v, dict): + out.update(flatten(v, f"{prefix}{k}/")) + else: + out[f"{prefix}{k}"] = v + return out + + for name, (orig, saved) in self.missions.items(): + with self.subTest(mission=name): + before = flatten(orig["mission"]["mission"]["weather"]) + after = flatten(saved["mission"]["mission"]["weather"]) + self.assertEqual(before, {k: after.get(k, "") for k in before}) + + def test_drawings_are_kept(self) -> None: + for name, (orig, saved) in self.missions.items(): + with self.subTest(mission=name): + before = orig["mission"]["mission"].get("drawings", {}).get("layers", {}) + after = saved["mission"]["mission"]["drawings"]["layers"] + for layer_key, layer in before.items(): + for object_key, drawing in layer["objects"].items(): + new = after[layer_key]["objects"][object_key] + self.assertFalse(set(drawing) - set(new), drawing["name"]) + self.assertEqual(drawing.get("hiddenOnPlanner"), new.get("hiddenOnPlanner")) From 1cc950ac85c128949f415d2e0dcaf61ae2cdb442 Mon Sep 17 00:00:00 2001 From: Rene Peinthor Date: Sun, 25 Oct 2026 10:55:12 +0100 Subject: [PATCH 15/22] Fix wrong formation values and vee formation crash OptFormation values encode formationIndex << 16 | zInverse << 8 | variantIndex: - RotaryEchelonRightMedium was 583826 instead of 589826, which decodes to an invalid formation - line_abreast_group_close() used the LineAbreastClose value - rotary_front_right_medium() and rotary_front_left_medium() set the close variant although their values encode the open one VehicleGroup.formation_vee() raised an IndexError for groups with an even number of units. --- dcs/task.py | 8 ++++---- dcs/unitgroup.py | 2 +- tests/test_task.py | 32 ++++++++++++++++++++++++++++++++ tests/test_unitgroup.py | 17 +++++++++++++++++ 4 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 tests/test_unitgroup.py diff --git a/dcs/task.py b/dcs/task.py index 06cb88a5..5553a05d 100644 --- a/dcs/task.py +++ b/dcs/task.py @@ -1865,7 +1865,7 @@ class Values(IntEnum): # RotaryWedge has no VariantIndex or zInverse field. RotaryWedge = 8 RotaryEchelonRightSmall = 589825 - RotaryEchelonRightMedium = 583826 + RotaryEchelonRightMedium = 589826 RotaryEchelonRightLarge = 589827 RotaryEchelonLeftSmall = 590081 RotaryEchelonLeftMedium = 590082 @@ -2044,7 +2044,7 @@ def rotary_front_right_medium(): value=OptFormation.Values.RotaryFrontRightMedium, formationIndex=OptFormation.FormationIndex.RotaryFront, zInverse=OptFormation.ZInverse.Right, - variantIndex=OptFormation.VariantIndex.Close + variantIndex=OptFormation.VariantIndex.Open ) @staticmethod @@ -2062,7 +2062,7 @@ def rotary_front_left_medium(): value=OptFormation.Values.RotaryFrontLeftMedium, formationIndex=OptFormation.FormationIndex.RotaryFront, zInverse=OptFormation.ZInverse.Left, - variantIndex=OptFormation.VariantIndex.Close + variantIndex=OptFormation.VariantIndex.Open ) @staticmethod @@ -2096,7 +2096,7 @@ def line_abreast_open(): @staticmethod def line_abreast_group_close(): return OptFormation( - value=OptFormation.Values.LineAbreastClose, + value=OptFormation.Values.LineAbreastGroupClose, formationIndex=OptFormation.FormationIndex.LineAbreast, zInverse=None, variantIndex=OptFormation.VariantIndex.GroupClose diff --git a/dcs/unitgroup.py b/dcs/unitgroup.py index ebaceea1..e6531a9e 100644 --- a/dcs/unitgroup.py +++ b/dcs/unitgroup.py @@ -169,7 +169,7 @@ def formation_vee(self, heading=0, distance=20): u_idx += 1 - if u_idx <= unit_count: + if u_idx < unit_count: self.units[u_idx].position = start_pos.point_from_heading(heading - 225, distance * (i + 1)) self.units[u_idx].heading = self.units[0].heading u_idx += 1 diff --git a/tests/test_task.py b/tests/test_task.py index ab710bf3..75687070 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -57,3 +57,35 @@ def test_mission_with_tasks_reloads(self) -> None: self.assertEqual(m2.load_file(path), []) loaded = m2.country("USA").plane_group[0].points[0].tasks[-len(tasks):] self.assertEqual([type(t) for t in loaded], [type(t) for t in tasks]) + + +class FormationTests(unittest.TestCase): + + @staticmethod + def _decode(value: int): + # formation values are encoded as formationIndex << 16 | zInverse << 8 | variantIndex + return value >> 16, (value >> 8) & 0xFF, value & 0xFF + + def test_values_decode_to_known_formations(self) -> None: + formations = {int(x) for x in task.OptFormation.FormationIndex} + for name, value in task.OptFormation.Values.__members__.items(): + if name == "RotaryWedge": + continue # DCS uses the plain value 8 for it + with self.subTest(name): + formation, z_inverse, variant = self._decode(int(value)) + self.assertIn(formation, formations) + self.assertIn(z_inverse, (0, 1)) + self.assertIn(variant, (0, 1, 2, 3)) + + def test_factories_match_their_value(self) -> None: + for name, factory in inspect.getmembers(task.OptFormation, predicate=inspect.isfunction): + if name.startswith("_") or inspect.signature(factory).parameters: + continue + params = factory().params["action"]["params"] + if params["value"] == task.OptFormation.Values.RotaryWedge: + continue + with self.subTest(name): + formation, z_inverse, variant = self._decode(int(params["value"])) + self.assertEqual(params["formationIndex"], formation) + self.assertEqual(params.get("variantIndex", 0), variant) + self.assertEqual(params.get("zInverse", 0), z_inverse) diff --git a/tests/test_unitgroup.py b/tests/test_unitgroup.py new file mode 100644 index 00000000..b07e98af --- /dev/null +++ b/tests/test_unitgroup.py @@ -0,0 +1,17 @@ +import unittest + +import dcs +from dcs.unitgroup import VehicleGroup + + +class VehicleGroupFormationTests(unittest.TestCase): + def test_all_formations_for_any_group_size(self) -> None: + for formation in VehicleGroup.Formation: + for size in range(1, 8): + with self.subTest(formation=formation.name, size=size): + m = dcs.Mission() + group = m.vehicle_group(m.country("USA"), "group", dcs.vehicles.Armor.M_1_Abrams, + m.terrain.airports["Batumi"].position, group_size=size) + group.formation(formation, heading=90) + positions = {(round(u.position.x, 3), round(u.position.y, 3)) for u in group.units} + self.assertEqual(len(positions), size) From 7a39bb85baa626fda59da4859b26373d9a7de8ea Mon Sep 17 00:00:00 2001 From: Rene Peinthor Date: Sun, 25 Oct 2026 10:55:48 +0100 Subject: [PATCH 16/22] Give each airport its own unit_zones list unit_zones is declared as a class attribute on the airport classes and Caucasus.__init__ appended its zones to it, so every new Caucasus() added them again (Batumi had 3 zones after one instance, 6 after two) and older instances saw the zones of newer ones, which also kept old terrain objects alive. Copy the class default into each instance. --- dcs/terrain/terrain.py | 2 ++ tests/test_terrain.py | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/dcs/terrain/terrain.py b/dcs/terrain/terrain.py index 0f76b9d0..4a557c17 100644 --- a/dcs/terrain/terrain.py +++ b/dcs/terrain/terrain.py @@ -201,6 +201,8 @@ class Airport(Warehouse): def __init__(self, position: mapping.Point, terrain: Terrain) -> None: self.position = position self._terrain = terrain + # per instance copy, the terrains add their zones to it and must not change the class default + self.unit_zones = list(self.unit_zones) self.runway_used = None self.runways = [] # type: List[Runway] self.parking_slots = [] # type: List[ParkingSlot] diff --git a/tests/test_terrain.py b/tests/test_terrain.py index 0e9e5e3a..3999522f 100644 --- a/tests/test_terrain.py +++ b/tests/test_terrain.py @@ -182,3 +182,14 @@ def test_airplane_parking_used(self): hslots = m.terrain.airports["Aleppo"].free_parking_slots(dcs.helicopters.UH_1H) self.assertEqual(len(hslots), 8) + +class AirportTests(unittest.TestCase): + def test_unit_zones_are_per_instance(self) -> None: + first = dcs.terrain.Caucasus() + zones = len(first.airports["Batumi"].unit_zones) + self.assertGreater(zones, 0) + + second = dcs.terrain.Caucasus() + self.assertEqual(len(first.airports["Batumi"].unit_zones), zones) + self.assertEqual(len(second.airports["Batumi"].unit_zones), zones) + self.assertTrue(all(z._terrain is second for z in second.airports["Batumi"].unit_zones)) From 59178ff9f0871fd9795567a49b452601b024da90 Mon Sep 17 00:00:00 2001 From: Rene Peinthor Date: Sun, 25 Oct 2026 10:56:40 +0100 Subject: [PATCH 17/22] Don't hand out EPLRS ids that are already in use next_eplrs() looked for the first gap by comparing the used ids in iteration order with a counter, so once the ids were not in ascending order (e.g. after removing a group and filling the gap from another coalition) it returned an id that was already taken. Return the smallest positive id that isn't used. --- dcs/mission.py | 6 ++---- tests/test_mission.py | 13 +++++++++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/dcs/mission.py b/dcs/mission.py index 08f90751..adf89645 100644 --- a/dcs/mission.py +++ b/dcs/mission.py @@ -594,11 +594,9 @@ def next_eplrs(self, group_type: str) -> int: Returns: int: the next eplrs id to use """ - eplrs_usage = self.eplrs_for(group_type) + used = set(self.eplrs_for(group_type).values()) eplrs_id = 1 - for ep_id in [eplrs_usage[x] for x in eplrs_usage]: - if ep_id != eplrs_id: - return eplrs_id + while eplrs_id in used: eplrs_id += 1 return eplrs_id diff --git a/tests/test_mission.py b/tests/test_mission.py index 06f0ff92..502fc732 100644 --- a/tests/test_mission.py +++ b/tests/test_mission.py @@ -1656,3 +1656,16 @@ def test_neutral_task_description(self) -> None: m2 = self._save_and_load(m) self.assertEqual(m2.description_neutralstask_text(), "neutral task") + + def test_next_eplrs_skips_used_ids(self) -> None: + m = dcs.Mission() + pos = m.terrain.airports["Batumi"].position + usa, russia = m.country("USA"), m.country("Russia") + a = m.vehicle_group(usa, "A", dcs.vehicles.Armor.M_1_Abrams, pos) + m.vehicle_group(usa, "B", dcs.vehicles.Armor.M_1_Abrams, pos) + usa.vehicle_group.remove(a) + m.vehicle_group(russia, "C", dcs.vehicles.Armor.T_90, pos) + m.vehicle_group(usa, "D", dcs.vehicles.Armor.M_1_Abrams, pos) + + ids = list(m.eplrs_for("vehicle").values()) + self.assertEqual(sorted(ids), [1, 2, 3]) From 4bb6656254dfe603d78a5142d31b728624b3e618 Mon Sep 17 00:00:00 2001 From: Rene Peinthor Date: Sun, 25 Oct 2026 10:58:34 +0100 Subject: [PATCH 18/22] Keep resource files with the same file name apart MapResource.store() named the files in the mission archive after the source file name only and skipped a file whose name was already used, while its resource key still pointed to that name. Adding red and blue briefing pictures that are both called brief.jpg made both coalitions show the red one; the same happened when a new file had the name of a resource of a loaded mission. A different file with an already used name now gets a unique one (brief_1.jpg). The same file used by several resource keys is still only written once, and loaded missions keep their file names. --- dcs/mission.py | 24 ++++++++++++++++++------ tests/test_mission.py | 20 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/dcs/mission.py b/dcs/mission.py index adf89645..a43d9473 100644 --- a/dcs/mission.py +++ b/dcs/mission.py @@ -2313,16 +2313,28 @@ def store(self, zipf: zipfile.ZipFile, lang='DEFAULT'): zipf.write(file["path"], file["respath"]) if lang in self.files: + names_in_zip = set(zipf.namelist()) + written: Dict[str, str] = {} # name in zip -> source file for reskey in self.files[lang]: filepath = self.files[lang][reskey] if os.path.isabs(filepath): nameinzip = os.path.basename(filepath) - zippath = "l10n/{lang}/{name}".format(lang=lang, name=nameinzip) - # do not write files twice - # if a script is called multiple times, a resource key is duplicated for the same file - has_file = [x for x in zipf.filelist if x.filename == zippath] - if not has_file: - zipf.write(filepath, zippath) + stem, ext = os.path.splitext(nameinzip) + n = 1 + + def taken(name: str) -> bool: + if name in written: + return written[name] != filepath + return "l10n/{lang}/{name}".format(lang=lang, name=name) in names_in_zip + + # the same file can be used by several resource keys and is only written once, + # a different file with an already used name gets a unique one + while taken(nameinzip): + nameinzip = "{stem}_{n}{ext}".format(stem=stem, n=n, ext=ext) + n += 1 + if nameinzip not in written: + zipf.write(filepath, "l10n/{lang}/{name}".format(lang=lang, name=nameinzip)) + written[nameinzip] = filepath d[reskey] = nameinzip return d diff --git a/tests/test_mission.py b/tests/test_mission.py index 502fc732..327290b1 100644 --- a/tests/test_mission.py +++ b/tests/test_mission.py @@ -1669,3 +1669,23 @@ def test_next_eplrs_skips_used_ids(self) -> None: ids = list(m.eplrs_for("vehicle").values()) self.assertEqual(sorted(ids), [1, 2, 3]) + + def test_resource_files_with_same_name(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + for side, content in (("red", b"RED"), ("blue", b"BLUE")): + os.makedirs(os.path.join(tmp, side)) + with open(os.path.join(tmp, side, "brief.jpg"), "wb") as f: + f.write(content) + m = dcs.Mission() + red = m.add_picture_red(os.path.join(tmp, "red", "brief.jpg")) + blue = m.add_picture_blue(os.path.join(tmp, "blue", "brief.jpg")) + red_again = m.map_resource.add_resource_file(os.path.join(tmp, "red", "brief.jpg")) + path = os.path.join(tmp, "pictures.miz") + m.save(path) + + with zipfile.ZipFile(path) as miz: + resources = dcs.lua.loads(miz.read("l10n/DEFAULT/mapResource").decode())["mapResource"] + self.assertEqual(miz.read("l10n/DEFAULT/" + resources[red.key]), b"RED") + self.assertEqual(miz.read("l10n/DEFAULT/" + resources[blue.key]), b"BLUE") + self.assertEqual(resources[red_again.key], resources[red.key]) + self.assertEqual(len(miz.namelist()), len(set(miz.namelist()))) From 3cd3ed0c81c4ee64562f1c2182ad9745b6624036 Mon Sep 17 00:00:00 2001 From: Rene Peinthor Date: Sun, 25 Oct 2026 10:59:36 +0100 Subject: [PATCH 19/22] Read non-ASCII resource file names of missions correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mission editor stores UTF-8 file names in the mission archive without setting the archive's UTF-8 flag, so Python decoded them as cp437. Resources with non-ASCII names (e.g. briefing pictures named "Campaña_512.png") were then not found: they were dropped from mapResource and written back under garbled names on save. Read the names as UTF-8 and fall back to the zip default when they aren't valid UTF-8. The round-trip tests now also check that resources and archive files are kept. --- dcs/mission.py | 15 ++++++++++++++- tests/test_roundtrip.py | 19 +++++++++++++++++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/dcs/mission.py b/dcs/mission.py index a43d9473..2af2d6e2 100644 --- a/dcs/mission.py +++ b/dcs/mission.py @@ -75,6 +75,19 @@ def from_string(s: str): return st_map[s.lower()] +def _open_miz(filename: str) -> zipfile.ZipFile: + """Opens a mission file for reading. + + The mission editor stores UTF-8 file names without setting the archive's UTF-8 flag, so + they would be read as cp437 and resources with non-ASCII names would not be found. + """ + try: + return zipfile.ZipFile(filename, 'r', metadata_encoding='utf-8') + except UnicodeDecodeError: + # names that are not valid UTF-8, keep the zip standard's default + return zipfile.ZipFile(filename, 'r') + + class Mission: """This class represents the whole dcs .miz file. @@ -246,7 +259,7 @@ def loaddict(fname: str, mizfile: zipfile.ZipFile, reserved_files: List[str]) -> data = mfile.read().decode() return lua.loads(data) - with zipfile.ZipFile(filename, 'r') as miz: + with _open_miz(filename) as miz: reserved_files: List[str] = [] mission_dict = loaddict('mission', miz, reserved_files) diff --git a/tests/test_roundtrip.py b/tests/test_roundtrip.py index 36a9f166..cc7daa6f 100644 --- a/tests/test_roundtrip.py +++ b/tests/test_roundtrip.py @@ -16,8 +16,12 @@ def _read_miz(path: str) -> Dict[str, Any]: - with zipfile.ZipFile(path) as miz: - return {name: loads(miz.read(name).decode("utf-8")) for name in ("mission", "options")} + # the mission editor writes UTF-8 file names without setting the archive's UTF-8 flag + with zipfile.ZipFile(path, metadata_encoding="utf-8") as miz: + content = {name: loads(miz.read(name).decode("utf-8")) for name in ("mission", "options")} + content["files"] = miz.namelist() + content["mapResource"] = loads(miz.read("l10n/DEFAULT/mapResource").decode("utf-8"))["mapResource"] + return content def _groups(mission: Dict[str, Any]) -> Iterator[Tuple[str, Dict[str, Any]]]: @@ -162,3 +166,14 @@ def test_drawings_are_kept(self) -> None: new = after[layer_key]["objects"][object_key] self.assertFalse(set(drawing) - set(new), drawing["name"]) self.assertEqual(drawing.get("hiddenOnPlanner"), new.get("hiddenOnPlanner")) + + + def test_resources_are_kept(self) -> None: + for name, (orig, saved) in self.missions.items(): + with self.subTest(mission=name): + # resources whose file is missing in the original mission can't be kept + expected = {k: v for k, v in orig["mapResource"].items() + if k and "l10n/DEFAULT/" + v in orig["files"]} + self.assertEqual(expected, {k: v for k, v in saved["mapResource"].items() if k in expected}) + self.assertFalse(set(orig["files"]) - set(saved["files"]), "files lost") + self.assertEqual(len(saved["files"]), len(set(saved["files"])), "duplicate zip entries") From 85303986dd5b03215576c8c2bd2e8ac8f85cc58a Mon Sep 17 00:00:00 2001 From: Rene Peinthor Date: Sun, 25 Oct 2026 11:02:11 +0100 Subject: [PATCH 20/22] Don't keep state of a previous mission in load_file() load_file() promises to replace all current data, but kept: - the briefing pictures, which were appended to, so reload() doubled them - the resource files, so saving after reload() wrote duplicate archive entries - forced options the new mission doesn't set - aircraft kneeboards added before the load - a neutral coalition when the new mission has none - the map view, which stayed bound to the previous terrain (a Nevada mission loaded into Mission() got its map position in the Caucasus) Reset them at the start of the load, and take the map view from the loaded terrain. --- dcs/mission.py | 10 ++++++++++ tests/test_mission.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/dcs/mission.py b/dcs/mission.py index 2af2d6e2..e43ccc69 100644 --- a/dcs/mission.py +++ b/dcs/mission.py @@ -253,6 +253,14 @@ def load_file(self, filename: str, bypass_triggers: bool = False) -> List[Status self.tmpdir = tempfile.mkdtemp() status = [] + # state that is filled from the file or added to afterwards must not survive from a previous load + self.map_resource = MapResource(self) + self.pictureFileNameR = [] + self.pictureFileNameB = [] + self.pictureFileNameN = [] + self.forced_options = ForcedOptions() + self.aircraft_kneeboards = defaultdict(list) + def loaddict(fname: str, mizfile: zipfile.ZipFile, reserved_files: List[str]) -> Dict[str, Any]: reserved_files.append(fname) with mizfile.open(fname) as mfile: @@ -377,6 +385,7 @@ def loaddict(fname: str, mizfile: zipfile.ZipFile, reserved_files: List[str]) -> self.init_script = imp_mission.get("initScript") # import coalition with countries and units + self.coalition = {} for col_name in ["blue", "red", "neutrals"]: if col_name in imp_mission["coalition"]: self.coalition[col_name] = Coalition(col_name, imp_mission["coalition"][col_name]["bullseye"]) @@ -413,6 +422,7 @@ def loaddict(fname: str, mizfile: zipfile.ZipFile, reserved_files: List[str]) -> self.forced_options.load_from_dict(imp_mission["forcedOptions"]) # map + self.map = copy.copy(self.terrain.map_view_default) self.map.load_from_dict(imp_mission["map"]) # weather diff --git a/tests/test_mission.py b/tests/test_mission.py index 327290b1..13c9b2ae 100644 --- a/tests/test_mission.py +++ b/tests/test_mission.py @@ -4,6 +4,7 @@ from unittest import mock from pathlib import Path import tempfile +import warnings import zipfile import math @@ -1689,3 +1690,45 @@ def test_resource_files_with_same_name(self) -> None: self.assertEqual(miz.read("l10n/DEFAULT/" + resources[blue.key]), b"BLUE") self.assertEqual(resources[red_again.key], resources[red.key]) self.assertEqual(len(miz.namelist()), len(set(miz.namelist()))) + + def test_load_file_replaces_previous_state(self) -> None: + m = dcs.Mission() + m.load_file("tests/missions/LUNA.miz") + pictures = list(m.pictureFileNameR) + self.assertTrue(pictures) + m.reload() + self.assertEqual(m.pictureFileNameR, pictures) + + # a reloaded mission is saved without duplicate archive entries + m = dcs.Mission() + m.load_file("tests/missions/a_out_picture.miz") + m.reload() + with tempfile.TemporaryDirectory() as tmp, warnings.catch_warnings(): + warnings.simplefilter("error") + path = os.path.join(tmp, "reloaded.miz") + m.save(path) + with zipfile.ZipFile(path) as miz: + self.assertEqual(len(miz.namelist()), len(set(miz.namelist()))) + + # nothing of the first mission is kept + m = dcs.Mission() + m.load_file("tests/missions/Coop_OP_Custodia_v1.miz") + m.add_aircraft_kneeboard(dcs.planes.FA_18C_hornet, Path("tests/images/blue.png")) + m.load_file("tests/loadtest.miz") + fresh = dcs.Mission() + fresh.load_file("tests/loadtest.miz") + self.assertEqual(m.forced_options.dict(), fresh.forced_options.dict()) + self.assertEqual(m.aircraft_kneeboards, {}) + + # a mission without neutral coalition doesn't keep the one of the previous mission + m.load_file("tests/missions/Coop_OP_Custodia_v1.miz") + self.assertEqual(set(m.coalition), {"blue", "red"}) + + def test_load_file_uses_map_view_of_loaded_terrain(self) -> None: + m = dcs.Mission() # Caucasus + m.load_file("tests/missions/Forestry_Operations.miz") # Nevada + self.assertIsInstance(m.terrain, dcs.terrain.Nevada) + self.assertIs(m.map._terrain, m.terrain) + position = m.map.position.latlng() + self.assertAlmostEqual(position.lat, 36.27, places=1) + self.assertAlmostEqual(position.lng, -115.57, places=1) From 91bbeb3170596a3216f64f0ec3335bac5ec7708a Mon Sep 17 00:00:00 2001 From: Rene Peinthor Date: Sun, 25 Oct 2026 11:02:58 +0100 Subject: [PATCH 21/22] Don't fail to load missions using an airport pydcs doesn't know When a parked aircraft group started at an airdrome id missing from the pydcs terrain data (e.g. an airfield added in a newer DCS version), loading aborted with "'NoneType' object has no attribute 'parking_slot'". Add an AIRPORT_UNKNOWN warning to the load status instead and keep the parking as loaded, so it is written back unchanged. --- dcs/coalition.py | 7 +++++++ dcs/status_message.py | 1 + tests/test_mission.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/dcs/coalition.py b/dcs/coalition.py index 233e26d2..7e2e9e93 100644 --- a/dcs/coalition.py +++ b/dcs/coalition.py @@ -64,6 +64,13 @@ def _park_unit_on_airport( ret: List[StatusMessage] = [] if group.points[0].airdrome_id is not None and unit.parking is not None: airport = mission.terrain.airport_by_id(group.points[0].airdrome_id) + if airport is None: + # e.g. an airfield added in a newer DCS version, keep the parking as loaded + msg = "Unknown airport id '{i}' for unit '{u}' in group '{p}', keeping its parking slot " \ + "'{s}' unchecked".format(i=group.points[0].airdrome_id, u=unit.name, p=group.name, s=unit.parking) + print("WARN", msg, file=sys.stderr) + ret.append(StatusMessage(msg, MessageType.AIRPORT_UNKNOWN, MessageSeverity.WARN)) + return ret slot = airport.parking_slot(unit.parking) if slot is not None: unit.set_parking(slot) diff --git a/dcs/status_message.py b/dcs/status_message.py index 52db6f1f..fc979d8c 100644 --- a/dcs/status_message.py +++ b/dcs/status_message.py @@ -12,6 +12,7 @@ class MessageType(IntEnum): PARKING_SLOT_NOT_VALID = 1 PARKING_SLOTS_FULL = 3 MISSION_FORMAT_OLD = 4 + AIRPORT_UNKNOWN = 5 class StatusMessage: diff --git a/tests/test_mission.py b/tests/test_mission.py index 13c9b2ae..b42fb9ce 100644 --- a/tests/test_mission.py +++ b/tests/test_mission.py @@ -1732,3 +1732,34 @@ def test_load_file_uses_map_view_of_loaded_terrain(self) -> None: position = m.map.position.latlng() self.assertAlmostEqual(position.lat, 36.27, places=1) self.assertAlmostEqual(position.lng, -115.57, places=1) + + def test_load_unknown_airport(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + # move a parked plane group to an airport id pydcs doesn't know + path = os.path.join(tmp, "unknown_airport.miz") + with zipfile.ZipFile("tests/loadtest.miz") as src, zipfile.ZipFile(path, "w") as dst: + for item in src.infolist(): + data = src.read(item.filename) + if item.filename == "mission": + mission = dcs.lua.loads(data.decode())["mission"] + group = next(g for c in mission["coalition"]["blue"]["country"].values() + for g in c.get("plane", {}).get("group", {}).values() + if g["route"]["points"][1].get("airdromeId") is not None + and g["units"][1].get("parking") is not None) + group["route"]["points"][1]["airdromeId"] = 999 + data = dcs.lua.dumps(mission, "mission", 1).encode() + dst.writestr(item, data) + + m = dcs.Mission() + status = m.load_file(path) + self.assertIn(dcs.status_message.MessageType.AIRPORT_UNKNOWN, [s.type for s in status]) + + loaded = m.find_group(group["name"]) + saved = os.path.join(tmp, "saved.miz") + m.save(saved) + m2 = dcs.Mission() + status = m2.load_file(saved) + self.assertEqual({s.type for s in status}, {dcs.status_message.MessageType.AIRPORT_UNKNOWN}) + reloaded = m2.find_group(group["name"]) + self.assertEqual(reloaded.points[0].airdrome_id, 999) + self.assertEqual(reloaded.units[0].parking, loaded.units[0].parking) From de0613dad956ec0dee0cc20c9b39f5a2e50e5a8d Mon Sep 17 00:00:00 2001 From: Rene Peinthor Date: Sun, 25 Oct 2026 11:03:55 +0100 Subject: [PATCH 22/22] Fix broken vehicle templates - sa15_site failed with an AttributeError: it still used unit class names that changed with the 2.7 data exports (Dog Ear radar, Tor and the AK infantry). Use the current classes for the same DCS unit ids. - USA.patriot_site and USA.hawk_site built the site but didn't return the group, so callers got None. Add a smoke test that creates every template, so renames from future data exports are noticed. --- dcs/templates.py | 8 +++++--- tests/test_templates.py | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 tests/test_templates.py diff --git a/dcs/templates.py b/dcs/templates.py index 6b59586e..f59b4cb0 100644 --- a/dcs/templates.py +++ b/dcs/templates.py @@ -90,6 +90,7 @@ def patriot_site(mission: Mission, position, heading, prefix="", skill=unit.Skil for u in vg.units: u.skill = skill + return vg @staticmethod def hawk_site(mission: Mission, position, heading, prefix="", skill=unit.Skill.Average): @@ -135,6 +136,7 @@ def hawk_site(mission: Mission, position, heading, prefix="", skill=unit.Skill.A for u in vg.units: u.skill = skill + return vg @staticmethod def sa11_site(mission, country, position, heading, prefix="", skill=unit.Skill.Average): @@ -167,16 +169,16 @@ def sa11_site(mission, country, position, heading, prefix="", skill=unit.Skill.A @staticmethod def sa15_site(mission, country, position, heading, prefix="", skill=unit.Skill.Average): vg = mission.vehicle_group(country, prefix + "SA15 site", - dcs.vehicles.AirDefence.MCC_SR_Sborka_Dog_Ear_SR, position, heading) + dcs.vehicles.AirDefence.Dog_Ear_radar, position, heading) - u = mission.vehicle("Operator 1", Russia.Vehicle.Infantry.Infantry_AK_74_Rus) + u = mission.vehicle("Operator 1", Russia.Vehicle.Infantry.Infantry_AK) u.position = position.point_from_heading(heading + 180, 10) u.heading = heading vg.add_unit(u) hdg = 90 for i in range(0, 4): # 4 tor vehicles - u = mission.vehicle("tor #" + str(i + 1), dcs.vehicles.AirDefence.SAM_SA_15_Tor_Gauntlet) + u = mission.vehicle("tor #" + str(i + 1), dcs.vehicles.AirDefence.Tor_9A331) u.position = position.point_from_heading(heading + hdg, 50) u.heading = heading vg.add_unit(u) diff --git a/tests/test_templates.py b/tests/test_templates.py new file mode 100644 index 00000000..580d172f --- /dev/null +++ b/tests/test_templates.py @@ -0,0 +1,23 @@ +import unittest + +import dcs +from dcs.templates import ShipTemplate, VehicleTemplate + + +class TemplateTests(unittest.TestCase): + def test_all_templates(self) -> None: + templates = { + "sa10_site": lambda m, pos: VehicleTemplate.Russia.sa10_site(m, pos, 90), + "patriot_site": lambda m, pos: VehicleTemplate.USA.patriot_site(m, pos, 90), + "hawk_site": lambda m, pos: VehicleTemplate.USA.hawk_site(m, pos, 90), + "sa11_site": lambda m, pos: VehicleTemplate.sa11_site(m, m.country("Russia"), pos, 90), + "sa15_site": lambda m, pos: VehicleTemplate.sa15_site(m, m.country("Russia"), pos, 90), + "sa6_site": lambda m, pos: VehicleTemplate.sa6_site(m, m.country("Russia"), pos, 90), + "kuznetsov_taskgroup": lambda m, pos: ShipTemplate.kuznetsov_taskgroup(m, pos, 90), + } + for name, create in templates.items(): + with self.subTest(name): + m = dcs.Mission() + group = create(m, m.terrain.airports["Batumi"].position) + self.assertIsNotNone(group) + self.assertGreater(len(group.units), 1)