Skip to content

feat: read CODESYS / Machine Expert .project files offline - #6

Merged
SmailG merged 6 commits into
mainfrom
feat/codesys-project-reader
Nov 9, 2026
Merged

SmailG merged 6 commits into
mainfrom
feat/codesys-project-reader

Conversation

@SmailG

@SmailG SmailG commented Nov 9, 2026 •

Copy link
Copy Markdown
Owner

What

A reader for CODESYS 3.5 / EcoStruxure Machine Expert .project files, so a repository that commits only the binary .project is no longer opaque to the plugin. Today the plugin reads PLCopen XML exports only.

  • scripts/codesys_project.py (standard library, Python 3.9+): list, st <name|guid>, dump-st <outdir>, graph <name|guid> [--json], and three commands that compare and identify builds:
    • info <project>: each device application's CodeId / DataId (the build identity the IDE compares with a controller) and the project's last save time. Both byte orders of the id are printed, because which one the IDE shows was not checked. There is no compile time in the project; the reference page says where it lives (the .compileinfo beside the project file, not in the zip) and how to match a saved project to a running PLC, or to a behaviour change in logged data.
    • diff <old> <new> [<name|guid>]: with a POU, the unified diff of its declaration and implementation (ST) or of its decoded LD/FBD graph; without one, the objects added, removed and changed (matched by guid). For graphs, element ids, box header ids, byte offsets, empty-slot ids and unreadable-record bytes are left out, so only real changes show: elements, operands, constants (500 to 600 shows), modifiers, box types and instances, network comments.
    • xref <project> <name>: every ST and LD/FBD use of a variable, instance or type name, with a read / write role where one can be told. All heuristic: by name only, case-insensitive, no scope resolution.
      Modules in scripts/codesysproj/ (buildinfo.py, diff.py, xref.py are new).
  • skills/other-platforms/references/codesys-project-files.md: what is decoded, what is a heuristic, what is not decoded, the safe workflow; sections on comparing versions, finding where a name is used, and which saved project matches a running PLC.
  • tests/test_codesys_project.py + tests/test_codesys_tools.py + tests/codesys_synth.py: tests on synthetic projects built in the test. No real project, name, text or guid is committed.
  • Plugin version stays 0.2.0 (this PR is the 0.2.0 release); README and CONTRIBUTING updated.

Trust level

The .project format is undocumented. The reader is reverse-engineered and was checked on three Machine Expert V2.x files (CODESYS 3.5.21.50) only. Nothing else is verified: other vendors, service packs, CFC/SFC/IL, methods, actions, properties, visualisations.

  • Decoded (read by the observed layout): zip container, string table, object guid / parent / name, the 20-byte object header, ST declaration and implementation text, network comments, element name / type / modifier byte, box type / instance / header id, pin names, and the two plain-XML entries behind info (devicesmetainformation.auxiliary, engineeringtoolsversions.auxiliary).
  • Heuristic (marked ~ in the text output and under heuristic in the JSON): object kind, IN/OUT, coil, modifier meaning (negated/set/reset), box nesting, every ids list, and every xref role.
  • Not decoded: the wiring. So diff cannot see a re-wire, and bytes that match no recognised record are skipped: "no differences" means no difference in the compared fields, and the output says so. xref cannot see objects of kind other (the device tree, I/O mappings) and says how many it did not search.
  • info is a lead, not a verdict. A CodeId match against the controller is decided in the IDE. Of 8 ids checked against the .bootinfo_guids beside each project, 7 were there in byte-reversed order and none in plain order; one CodeId was in neither, cause unknown (documented on the page).

Safe workflow, also in the reference page: prefer a PLCopen XML export from the IDE; use the reader for ST text, inventories and as a lead; verify graphical wiring in the IDE before acting on it.

Checks

  • python3 -m unittest discover -s tests: 276 tests pass on Python 3.14 and on 3.9.2. claude plugin validate . passes. git diff --check clean.
  • Mutation run on every parser guard and branch of the first commit: 82 mutants, 82 killed. On info, diff, xref and their CLI branches: 96 mutants, 96 killed. Two earlier runs left survivors (equivalent mutants removed, the rest missing tests, added); the whole run was repeated after each. A review of xref also found that a FUNCTION F : T or TYPE T : STRUCT header made the first variable after it read as a type and the keyword as a name; fixed with tests first (red, then green).
  • Against real files (kept out of the repo): the text of all 32 ST files of one project is byte-identical to an earlier independent extraction; 12 graphical objects reproduce all records of an earlier tree decoder, differing only in one modifier label, plus 35 empty slots the earlier pattern missed.
  • diff on the same real files: the graph diff of one POU between two saved versions found the same element, comment and box changes as the earlier text-dump diff, plus two four-digit constant changes that diff had normalised away; a POU with identical bytes in two versions gave no difference; the six ST POUs diff reported as changed between two versions were exactly the six whose dump-st files differ byte for byte. info printed the build identities and last save times the three files carry. For the one name compared, xref found the same graphical elements as an earlier regex tool, and the ST uses besides. A real function header and a real struct type read declared for their first variable. No real graphical object had different bytes and an equal normalised graph, so the id and offset normalisation is shown by the synthetic tests only.
  • No eval case added (optional per CONTRIBUTING).

Notes for review

  • dump-st file names are <Device>_<Name>, where Device is the root-most ancestor of the object (a heuristic); colliding names all get _<first 8 of the guid>.
  • st, graph, diff <pou> refuse an ambiguous name (exit 2) and list the candidates; a guid or a guid prefix of 8+ characters always works.
  • diff exits 0 when the two projects differ: a difference is its result. Exit 1 stays "result printed, but some object had a problem or a decoder note was raised".
  • Not decoded on purpose: a compile time (it is not in the project), the wiring, the device tree.
  • graph.py's renderers moved to graphview.py and xref's text scanner to stscan.py, so no file this PR adds under scripts/ is over 300 lines (a pure move; the first commit's graph.py was 303).

Review fixes (a65471b, 6b5e4b8, 1d42e7d)

An independent review of the whole PR found no leak of real project data. Fixed:

  • Hostile or damaged input. A CRC error, a corrupt deflate stream or an encrypted entry is that object's problem; the same failure on the string table is exit 2. No entry is read past 256 MiB. A commented-out declaration no longer makes the keyword regex backtrack for minutes. info refuses DOCTYPE/ENTITY in any encoding, through expat. A Windows console code page no longer crashes the output.
  • xref. Identifiers in any script; every name of a multi-line list, at its own line; EXTENDS and IMPLEMENTS read as types; a quoted constant in a graph element is not a name.
  • diff. A renamed POU is found by its old or its new name; each header carries its own version's name; PROBLEM lines say OLD or NEW; notes print even when an object also has a problem.
  • st and dump-st. They print GVL and DUT declarations. Names and guids match in any case.
  • Docs. info is a lead, and the IDE decides. The .compileinfo claims are marked unverified. The skill description names info, diff and xref.

Checks.

  • 24 mutants, one per new guard, each killed. Run with bytecode writing off: a stale .pyc had faked one result.
  • On three real project versions, list, info, diff and xref (40 names) give byte-identical output before and after.

Left as is, with the reason:

  • info silent on wrong nesting: covered by the new no-Device note.
  • Recursion on 600 nested brackets, U+FFFD after a bad string-table byte, quadratic graph work at 80k boxes: no realistic input.
  • short_id not checking the zero tail, and dump-st writing into an existing directory: behaviour to decide, not a bug.
  • newline on Windows: not testable on POSIX CI.
  • diff marking every descendant of a renamed ancestor as moved: true, and noisy only when the Application itself is renamed.

Verification pass: findings 1–13 are confirmed fixed. It found two new Mediums in the fix code, fixed in 1d42e7d, each with mutants that make a test fail:

  • one load now reads at most 1 GiB in total;
  • an EXTENDS / IMPLEMENTS clause on its own line no longer hides the first variable.

Also fixed in 1d42e7d: a pragma line before FUNCTION F : T used to break the header (an existing bug).

Left as is: the first enum member of a TYPE is no longer reported as declared. Enums were never parsed, and that one hit was an accident.

Summary by CodeRabbit

  • New Features
    • Added offline, read-only tools to inspect CODESYS and EcoStruxure Machine Expert project files, export program text, compare project versions, and search for references.
  • Documentation
    • Added usage guidance, supported formats, and limitations for the project-reading tools.
  • Tests
    • Added synthetic-project coverage for reading, exporting, comparing, searching, and handling malformed files.

Add scripts/codesys_project.py (list, st, dump-st, graph) and the
scripts/codesysproj/ modules behind it: the zip container, the shared
string table, .meta and .object records, ST declaration and implementation
text, and the element records and network comments of LD/FBD POUs.

The .project format is undocumented. The reader is reverse-engineered and
checked on Machine Expert V2.x files only; decoded facts and heuristics are
labelled in the output, graphical wiring is not decoded, and damaged
objects are reported instead of stopping the run.

Adds skills/other-platforms/references/codesys-project-files.md, links from
the other-platforms and schneider skills and from codesys.md, README and
CONTRIBUTING entries, and tests on synthetic projects (86 tests). Bumps the
plugin version to 0.2.0 in both plugin.json files.
@coderabbitai

coderabbitai Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

Explain Change →

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

Review info

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 7be59365-cde3-4140-a978-21fe7e7feb53
Commits

Reviewing files that changed from the base of the PR and between 651e8b8 and 1d42e7d.

Files selected for processing: 24

  • .claude-plugin/plugin.json
  • CONTRIBUTING.md
  • README.md
  • plugin.json
  • scripts/codesys_project.py
  • scripts/codesysproj/__init__.py
  • scripts/codesysproj/binary.py
  • scripts/codesysproj/buildinfo.py
  • scripts/codesysproj/diff.py
  • scripts/codesysproj/export.py
  • scripts/codesysproj/graph.py
  • scripts/codesysproj/graphview.py
  • scripts/codesysproj/project.py
  • scripts/codesysproj/stscan.py
  • scripts/codesysproj/xref.py
  • scripts/codesysproj/zipread.py
  • skills/other-platforms/SKILL.md
  • skills/other-platforms/references/codesys-project-files.md
  • skills/other-platforms/references/codesys.md
  • skills/schneider/SKILL.md
  • tests/codesys_synth.py
  • tests/test_codesys_hardening.py
  • tests/test_codesys_project.py
  • tests/test_codesys_tools.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

info prints each device application's CodeId / DataId (the build identity
the IDE compares with a controller) and the project's last save time, both
byte orders of the id, from the two plain-XML entries of the project. There
is no compile time in the project; the page says so and says how to match a
saved project to a running PLC.

diff compares one POU between two project files (ST text, declarations, and
the decoded LD/FBD graph with element ids, header ids, byte offsets, empty
slot ids and unreadable-record bytes left out, so constants, modifiers,
comments and boxes still show), or lists objects added, removed and changed
by guid. Wiring is not decoded and so not compared; the output says so.

xref lists every ST and LD/FBD use of a name with a read / write role where
one can be told, including a function's or type's own header. Name-based,
case-insensitive, no scope resolution; all of it is marked heuristic.

graph.py's renderers move to graphview.py and xref's text scanner to
stscan.py to keep files under 300 lines; no behaviour change there.

Tests are synthetic (two versions of one project, a build-identity entry, an
xref project, header declarations); 96 mutants of the new guards and
branches were run, none survived.
Input the reader is handed but did not write now ends in a message and an exit code:
- one reader for zip entries (zipread.py): a CRC error, a corrupt deflate stream or an
  encrypted entry is that object's problem, the string table's is fatal (exit 2), and no
  entry is read past 256 MiB (a zip bomb); a bad UTF-8 entry name is exit 2, not a traceback
- the declaration-keyword regex no longer backtracks on a commented-out declaration
- info refuses a DOCTYPE or ENTITY declaration in any encoding (expat, not a byte regex),
  notes an entry with no Device, and ignores a non-ASCII digit save counter
- output on a Windows console code page escapes characters it cannot print

xref: identifiers in any script, every name of a multi-line list at its own line, EXTENDS and
IMPLEMENTS as types, and a quoted constant in a graph element is not a name.
diff: a renamed POU is found by its old or new name, each header carries its own version's
name, PROBLEM lines say OLD or NEW, and notes are printed when an object also has a problem.
st and dump-st print GVL and DUT declarations; names and guids match in any case.
…ked; new behaviour

info points to which saved project a controller runs; the IDE's comparison decides. The
.compileinfo claims are marked unverified, the empty-string index is documented as observed,
and the reference page covers damaged entries, the entry cap, rename lookup in diff, and GVL /
DUT declarations in st and dump-st. The skill description names info, diff and xref.
…fore a header

From the verification pass: many entries just under the per-entry cap could still fill memory,
so one load reads at most 1 GiB in total (exit 2 past it); an EXTENDS or IMPLEMENTS clause on a
line of its own was read as a declared name and hid the first variable; a pragma line before
`FUNCTION F : T` cut the header's type at the pragma's line end.
@SmailG
SmailG marked this pull request as ready for review November 9, 2026 16:28
@SmailG
SmailG merged commit a35eb3b into main Nov 9, 2026
2 checks passed
@SmailG
SmailG deleted the feat/codesys-project-reader branch November 9, 2026 16:33
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