Skip to content

Add AGENTS.md, modernize packaging and CI, fix bugs found in a code review - #448

Merged
rp- merged 22 commits into
masterfrom
add-agents-md
Oct 25, 2026
Merged

rp- merged 22 commits into
masterfrom
add-agents-md

Conversation

@rp-

@rp- rp- commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator

This branch started as an AGENTS.md and grew into tooling updates plus a batch of bug fixes from a review of the hand-written code (generated data files excluded). Every fix is its own commit with a regression test that fails without it, so the commits can be reviewed (or dropped) one by one.

Tooling and CI

  • AGENTS.md: guidance for coding agents (setup, checks, architecture, generated files). CLAUDE.md only imports it.
  • Python versions: test and declare 3.11–3.15 only (3.10 is end of life since 2026-10-01). python_requires>=3.11.
  • Packaging: setup.py/Manifest.in replaced by pyproject.toml (setuptools backend, SPDX license LGPL-3.0-or-later). make build uses uv build, make upload still uses twine. The city graph pickles are now actually shipped (the old package_data pointed to non-existent files).
  • CI: tests run on Windows and Linux; a new job builds sdist/wheel with uv build, runs twine check --strict and smoke tests the installed wheel. Actions bumped to current versions.

Fixes

Scripts and platform

  • dcs_random pointed to a deleted module; it now runs nevada_random_mission, which (like dcs_oil_convoy) was broken by older API changes.
  • City graph pickles predate Point having a terrain; Graph.from_pickle() now takes the terrain (API change). Nevada loaded a non-existent nevada.p.
  • Livery IDs were the full path on non-Windows, and non-UTF-8 description.lua files aborted the livery scan there (ansi codec is Windows only).

Crashes and unloadable output

  • Lua parser couldn't read 1e+20 (which Python writes), lost precision on large integers and decoded escapes wrongly; the serializer didn't escape keys or carriage returns and wrote inf/nan. Both directions are now checked against lua5.1 behaviour.
  • Missions written by pydcs couldn't be loaded when they used RecoveryTanker, NoTask, OptJettisonEmptyTanks or FACAttackGroup; unknown tasks are now kept instead of aborting the load.
  • Goals of different sides overwrote each other on save; neutral countries with id 13 or ≥ 47 were missing from coalitions; a leftover breakpoint() in Unit.__init__.
  • MarkTo* actions emitted their arguments in the wrong order; picture actions and the radio item defaults (coalitionlist=all, the builtin) wrote invalid Lua.
  • Halo(Preset.Off) couldn't be saved; random_distant_points hung for tall rectangles.

Data lost on load → save (tests/test_roundtrip.py loads and saves all sample missions and checks nothing is lost)

  • Units/groups: train wagons, Link16 datalinks, data cartridges, ship modulation, transportable/cold start flags, uncontrollable, routeRelativeTOT, vehicle route spans, ship visibility, task names.
  • Mission: player name, unknown difficulty/forced options, neutral briefing task, coalition nav points.
  • Triggers: MessageToAll start delay, KeyDict_radiotext/comment, trigger colour, quad zone radius. Generated trigger code now matches the mission editor's for all samples.
  • Weather/drawings: fog density, unknown weather values, cloud values next to a preset, cyclone order, hiddenOnPlanner.
  • The kept values are explicit attributes written only when set (flat settings tables pass unknown keys through), so newly created missions are unchanged, except radio item/mark actions now also writing KeyDict_* like the mission editor.

Wrong values and state

  • Wrong OptFormation constants/factories; formation_vee crashed for even group sizes.
  • Caucasus unit_zones grew with every Caucasus() instance.
  • next_eplrs() could return an id in use.
  • Resource files with the same file name overwrote each other; non-ASCII resource names (stored by the mission editor without the zip UTF-8 flag) were lost.
  • load_file()/reload() kept pictures, resources, forced options, kneeboards, the neutral coalition and the map view of the previous mission.
  • Loading crashed on airdrome ids unknown to pydcs; now a new AIRPORT_UNKNOWN load warning.
  • sa15_site, patriot_site and hawk_site templates were broken.

Not included / open

  • Map bounds are wrong on 7 of 11 maps (swapped, copied from another map or too small); needs real extents.
  • Strings in non-default languages are never saved.
  • ActivateBeaconCommand gives ground/ship TACAN Y beacons system 5 (tanker Y) since 075949e; needs a check against the mission editor.
  • A few low-severity findings (e.g. format_dms for negative coordinates, shared default Rgba colours).

Testing

All 198 tests pass on Linux (2 Windows-only tests skipped), plus flake8 and mypy, for every commit. The new Windows/Linux CI matrix and the package job run for the first time with this PR.

rp- added 22 commits October 25, 2026 08:11
CLAUDE.md just imports AGENTS.md so Claude Code picks up the same instructions.
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.
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.
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.
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.
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
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.
- 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).
- 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".
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.
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.
- 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.
- 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.
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.
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.
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.
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.
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.
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.
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.
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.
- 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.
@rp-
rp- merged commit 84f6335 into master Oct 25, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant