Repository navigation
Add AGENTS.md, modernize packaging and CI, fix bugs found in a code review - #448
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
CLAUDE.mdonly imports it.python_requires>=3.11.setup.py/Manifest.inreplaced bypyproject.toml(setuptools backend, SPDX licenseLGPL-3.0-or-later).make buildusesuv build,make uploadstill uses twine. The city graph pickles are now actually shipped (the oldpackage_datapointed to non-existent files).uv build, runstwine check --strictand smoke tests the installed wheel. Actions bumped to current versions.Fixes
Scripts and platform
dcs_randompointed to a deleted module; it now runsnevada_random_mission, which (likedcs_oil_convoy) was broken by older API changes.Pointhaving a terrain;Graph.from_pickle()now takes the terrain (API change). Nevada loaded a non-existentnevada.p.description.luafiles aborted the livery scan there (ansicodec is Windows only).Crashes and unloadable output
1e+20(which Python writes), lost precision on large integers and decoded escapes wrongly; the serializer didn't escape keys or carriage returns and wroteinf/nan. Both directions are now checked againstlua5.1behaviour.RecoveryTanker,NoTask,OptJettisonEmptyTanksorFACAttackGroup; unknown tasks are now kept instead of aborting the load.coalitions; a leftoverbreakpoint()inUnit.__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_pointshung for tall rectangles.Data lost on load → save (
tests/test_roundtrip.pyloads and saves all sample missions and checks nothing is lost)uncontrollable,routeRelativeTOT, vehicle route spans, ship visibility, task names.MessageToAllstart delay,KeyDict_radiotext/comment, trigger colour, quad zone radius. Generated trigger code now matches the mission editor's for all samples.hiddenOnPlanner.KeyDict_*like the mission editor.Wrong values and state
OptFormationconstants/factories;formation_veecrashed for even group sizes.unit_zonesgrew with everyCaucasus()instance.next_eplrs()could return an id in use.load_file()/reload()kept pictures, resources, forced options, kneeboards, the neutral coalition and the map view of the previous mission.AIRPORT_UNKNOWNload warning.sa15_site,patriot_siteandhawk_sitetemplates were broken.Not included / open
ActivateBeaconCommandgives ground/ship TACAN Y beaconssystem5 (tanker Y) since 075949e; needs a check against the mission editor.format_dmsfor negative coordinates, shared defaultRgbacolours).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.