Skip to content

Release v0.6.5 — CONSTANT, RETAIN and NON_RETAIN variable qualifiers - #227

Merged
thiagoralves merged 16 commits into
mainfrom
development
Aug 31, 2026
Merged

Release v0.6.5 — CONSTANT, RETAIN and NON_RETAIN variable qualifiers#227
thiagoralves merged 16 commits into
mainfrom
development

Conversation

@dcoutinho1328

Copy link
Copy Markdown
Contributor

Promotes development to main for the v0.6.5 release.

What's in it

#222 — implements the IEC 61131-3 variable qualifiers CONSTANT, RETAIN and NON_RETAIN in the compiler, the way CODESYS does. Built across five staged PRs (#218#223), with paired PRs on openplc-editor, openplc-web and openplc-runtime.

  • CONSTANT marks a leaf read-only; the debugger refuses writes and forces with status 0x87.
  • RETAIN joins a leaf to the retain blob, restored at start as a plain write; NON_RETAIN opts a member back out of a retained container. PERSISTENT folds into RETAIN (CODESYS's distinction has no analogue here).
  • Qualifiers sit on the var block, so a block can carry a run of them, and NON_RETAIN clears the inherited bit rather than merely failing to set it — flags travel down the leaf walk as a parameter, never as shared mutable state.
  • VAR_IN_OUT, VAR_TEMP and VAR_EXTERNAL reject RETAIN (none owns storage); VAR_INPUT/VAR_OUTPUT accept it, matching CODESYS.
  • The retain blob is per-leaf granular, addressed by the same (arrayIdx, elemIdx) pairs the debug table uses. A 14-byte header carries magic, format, layout hash, payload length and crc32. The layout hash (FNV-1a over ordered path|typeTag of every retained leaf) is identity of the layout, not the program: a body edit keeps retained values, while adding/removing/retyping/reordering a retained variable invalidates them.
  • A retained function block instance retains everything it runs on, including library-block internals the consuming compilation can't see — the library compiler flattens each block and ships the result in the manifest (LibraryFBEntry.leaves); 224/224 bundled blocks produce a complete list. An archive built before this format is refused with a message naming the block, rather than silently retaining half of it.
  • debugMap.retainBlobSize is now emitted on DebugMapV2 so a consuming toolchain can static_assert a program's retain footprint against its target's storage at build time.

Compatibility

Additive to the manifest and debug-map formats. A program/archive with no retained variables serializes as before. debugMap.retainBlobSize is a new optional field — existing consumers that don't read it are unaffected.

Verification

  • Full suite 2322 passed, 7 skipped; tsc --noEmit clean, on development's current tip.

After merge

Tag v0.6.5 on main to trigger the release workflow. openplc-web and openplc-editor pin strucpp through binary-versions.json and need that release before their retain-variables PRs (openplc-web#691, openplc-editor#1034) can typecheck and pass CI — both currently fail on DebugMapV2.retainBlobSize not existing in the released v0.6.4.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PQ239CtwGVnDMSeLA2Lx93

thiagoralves and others added 15 commits August 24, 2026 12:22
A CONSTANT is emitted as a `const` C++ member, but the debug table reaches
every leaf through a C-style `(void*)` cast that silently strips the
qualifier — `static_cast` refuses the same conversion outright. Nothing
then stopped `handle_set` / `handle_write` from writing straight into a
genuinely const object: undefined behaviour, and a flat contradiction of
what CONSTANT means to the person who wrote it.

Nobody had hit this because no editor surface could create a const member
in the first place. The upcoming Flags column (blank / CONSTANT / RETAIN)
changes that on day one, so the gate has to land ahead of it.

Carry the qualifier through to the runtime instead of losing it at the
cast:

  - `Entry._pad` becomes `Entry.flags`, so the gate costs no flash and no
    RAM on any target — the byte was already there for alignment.
  - `LEAF_FLAG_READONLY` is set by debug-table-gen and checked by both
    mutating paths, which return the new `STATUS_READ_ONLY` (0x86, the
    next code free after the licensing FCs). Reads are untouched: watching
    a constant is useful, changing it is not. Unforce is refused too — a
    leaf that could never be forced has no force to clear, and reporting
    OK would claim otherwise.
  - The AVR paths in `read_entry` assemble `Entry` field by field, so they
    read the flags byte explicitly. A missed read there returns 0, which
    reads as "writable" and would defeat the gate on exactly the targets
    with the least room to spare.

The flag is threaded down the leaf walk as an explicit parameter rather
than a shared mutable, because a bit can be cleared partway down a subtree
— nothing does that today, but RETAIN will (a NON_RETAIN member inside a
RETAIN function-block instance), and a mutable would leak the cleared
value into the following sibling. It is OR-ed in at each declaring block,
so a `VAR CONSTANT` inside a function block is gated per instance rather
than only at program level, and CONSTANT structs and arrays propagate to
every field and element.

`DebugMapV2` gains an optional `readOnly` so the editor can hide the force
control instead of offering an action that will be refused. Advisory only
— the runtime is what enforces it, which is what keeps an older editor
build, or an OPC-UA client that never reads the map, safe. `version` stays
at 2 deliberately: the editor's debug-parser rejects anything else
outright, so a bump would break every editor pinned to an older strucpp
release the moment it read a new map.

Refs NODE-94

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012FNp61926UEggtmsQPj3A3
…y owns

0x86 collides with ModbusDebugResponse.REFUSED_BY_SWITCH in the editor's
shared status-code registry (added there before this branch existed), so
use the actual next-free code instead.
…onstant-gate

fix(debug): refuse writes and forces to CONSTANT variables
…orrectly

Phase 1 of NODE-94. The compiler now reads every IEC retention qualifier a
CODESYS project can carry, and RETAIN is allowed exactly where the standard
allows it.

**NON_RETAIN and PERSISTENT had no tokens.** `NON_RETAIN` lexed as an
Identifier, so `VAR NON_RETAIN x : DINT;` failed with "Expected Colon" on the
line BELOW the qualifier — an imported project died pointing at the wrong
place. Both are tokens now.

NON_RETAIN is the default spelled out, so nothing downstream branches on it. It
is still recorded on the block (`isNonRetain`) rather than dropped, so anything
reproducing source from the AST — the LSP formatter, `--decompile-lib` — keeps
it, and so `RETAIN NON_RETAIN` can be reported as the contradiction it is.

PERSISTENT folds into `isRetain`. CODESYS also keeps a PERSISTENT value across a
program download and this toolchain does not implement that, so the honest
mapping is the weaker guarantee both share; splitting them would claim something
nothing delivers. `docs/IEC_COMPLIANCE.md` now says Partial for PERSISTENT and
describes what NON_RETAIN actually does — the row previously claimed NON_RETAIN
was Supported with no token behind it.

**The qualifier is now a MANY, not an OPTION.** `VAR RETAIN PERSISTENT` is the
form a converted project carries, and IEC allows combinations. Contradictions
are semantic errors with a source span rather than a parser complaint about a
missing END_VAR: RETAIN+CONSTANT (as before), and now RETAIN+NON_RETAIN and
CONSTANT+NON_RETAIN.

**RETAIN in a FUNCTION or METHOD was silently accepted.** Neither has an
instance — a function is re-entered from scratch and a method's locals are stack
slots — so the qualifier had nothing to describe, and the user was left
believing a value survived a power cycle. Now an error. This needed a flag of
its own rather than reusing `scopeType`: a method reports "functionBlock" there
so its located variables are rejected the way an FB's are, and overloading it
would have changed that unrelated rule. The message deliberately names no
scope — the only name in reach is the owning FB's, and RETAIN on the FB's own
VAR is legal, so naming it would read as a contradiction.

**RETAIN on VAR_INPUT / VAR_OUTPUT is now allowed**, per IEC 61131-3 Table 13
and CODESYS. The old rule rejected function blocks that are valid everywhere
else. Two existing tests asserted that restriction; they are inverted rather
than deleted, so it cannot quietly return. VAR_IN_OUT, VAR_TEMP and
VAR_EXTERNAL stay refused — a reference, a transient, and a view onto a
VAR_GLOBAL respectively, none of which owns the storage the qualifier would
describe.

Found while testing, NOT fixed here: the POU var-block AST builder does not map
VAR_EXTERNAL, so a function block's external block arrives as blockType "VAR"
and slips past that rule. Pre-existing — the previous rule listed VAR_EXTERNAL
and was equally ineffective there — and fixing the mapping touches
located-variable validation and the external-resolution pass, so it is tracked
separately. Program-scope VAR_EXTERNAL RETAIN is caught, and there is a test at
that scope.

Full suite: 92 files, 2261 passed / 7 skipped (up 14). No new lint warnings.

Refs NODE-94

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012FNp61926UEggtmsQPj3A3
…etain-modifiers

feat(modifiers): accept NON_RETAIN and PERSISTENT, and scope RETAIN correctly
…shaller

Phase 2 of NODE-94. Replaces the Phase 2.6 retain scaffolding with something a
runtime can actually use, and adds the marshaller both hosts will share.

**The old descriptor could not work.** Each retained variable was
`{ name, offsetof(Class, member), sizeof(IECVar<T>) }`, and all three fields
were wrong for the job:

  - `sizeof(IECVar<T>)` is the whole wrapper. A DINT measures 12 bytes, not 4,
    and the extra 8 are `forced_` and `forced_value_` — so persisting that
    region carried the debugger's forcing state across a power cycle. Force a
    variable during commissioning, power-cycle, and it comes back forced with
    no debugger attached.
  - `offsetof` on a program class is `offsetof` on a non-standard-layout type
    (it derives from ProgramBase and has virtuals): conditionally supported,
    and it warns. The table also used the UNMANGLED member name while the class
    definition went through `mangleMemberIfNeeded`, so any retained variable
    whose name collided with its own type failed to compile.
  - It could only describe members of a PROGRAM. A retained variable inside a
    function block, or a retained CONFIGURATION global, compiled clean and was
    silently dropped.

**Retained leaves are now addressed by debug-table (arr, elem)** — the same
index the debugger already uses. That pass already walks every leaf, including
nested function-block members, struct fields, array elements and configuration
globals, and already reports each leaf's transport width, so all three problems
above disappear rather than being fixed: values move through
`handle_read` / `handle_write`, which touch the value and never the wrapper.

Selection rides the flags parameter Phase 0 introduced, which is why the
container rules fall out cleanly. `applyBlockFlags` ORs a block's own
qualifiers into whatever it inherited, and NON_RETAIN *clears* the bit — the
one case that makes a shared mutable wrong, since a cleared bit must not leak
into the following sibling. Verified: a retained FB instance retains its whole
subtree, a `VAR RETAIN` inside an FB retains in every instance including
non-retained ones, and a NON_RETAIN member opts out of a retained container two
levels up.

**`retain_layout_hash`** is FNV-1a over the ordered `path|typeTag` of the
retained leaves — identity of the LAYOUT, not of the program. A body edit keeps
retained values; adding, removing, retyping or reordering a retained variable
invalidates them. Keying on the project MD5 would have discarded retained state
on every unrelated edit.

**`iec_retain.hpp` becomes the shared marshaller.** The abstract `RetainStorage`
class it used to hold could never have crossed a `.so` or plugin boundary; it is
replaced by a blob format and a pack/unpack walk parameterised by read/write/size
function pointers, so the Arduino firmware and the v4 daemon share one
implementation and cannot drift. 14-byte header (magic, format, layout hash,
length, crc32) plus values packed in table order — no paths, no indices, no type
tags in the retain region, because that region is the scarce one.

Widths come from `size_of()` at runtime, never from the manifest: a STRING moves
as a fixed 127 bytes regardless of `STRING(20)`, and sizing the payload from
anything else desynchronises it from what the target can read.

`getRetainVars` / `getRetainCount` are KEPT as no-op base slots on ProgramBase
and deliberately no longer overridden. The v4 runtime mirrors that vtable by
position to dispatch `run()` across a `.so` boundary, so removing a slot would
mis-dispatch every program built against a different version.

Verified by building and running the real thing, not just compiling it: pack →
wipe → unpack restores a program local, an inherited FB member, an FB-local
RETAIN inside a NON-retained instance and a configuration global; leaves a
NON_RETAIN member untouched; does not force anything; and refuses a corrupt
payload, a stale layout, bad magic, an empty store and a truncated blob. That
round-trip is now a test.

Four tests asserting the removed table were rewritten rather than deleted. Two
also gained a CONFIGURATION, because retained storage exists in an INSTANCE —
an uninstantiated program has nothing to retain, which the old per-program
table obscured.

Suite: 92 files, 2273 passed / 7 skipped (up 12). No lint errors.

Refs NODE-94

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012FNp61926UEggtmsQPj3A3
…etain-table

feat(retain): leaf-addressed retain table, blob format and shared marshaller
…ust its interface

A retained library FB kept only what the .stlib manifest exposed — its
inputs, outputs and in-outs. Everything the block actually runs on stayed
behind. A retained TON came back with Q and ET but without STATE,
PREV_IN or START_TIME, so on the next start it saw no rising edge to
explain the values it held and restarted its wait: a block restored into
a configuration it could never have reached by running. That is worse
than not retaining it at all, because it looks like it worked.

The consumer cannot fix this for itself. Two things stop it:

  * Mangling is decided against the DECLARING unit. `mangledMemberName`
    adds a trailing underscore when a member's name matches its own
    type's name AND that type is user-defined, or when it collides with
    a method of an interface the owning FB implements — both answered
    from the library's own AST and symbol tables. A library-internal
    type never reaches the manifest, so a consumer resolves it as "not
    user-defined" and names a member the class does not declare.
    `generated_debug.cpp` then fails to compile and takes the firmware
    build with it.

  * Depth is invisible. A local may be a library-internal STRUCT or
    another FB instance, neither of them exported. Walking only what the
    manifest exports stops at the first one — the same partial retain,
    one level down.

So the library compiler runs the walk itself and writes the answer down:
`LibraryFBEntry.leaves` carries every persistent leaf of one instance,
flattened through structs, arrays and nested instances, each with its
path, its already-mangled C++ expression and its type. All 224 function
blocks across the five bundled archives produce a complete list,
OSCAT's 172 included.

One walk, not two. The flattening moved to `leaf-walker.ts`, shared by
the debug table and the library compiler, because they decide the C++
member name of every entry and had agreed only by copy. `TAG`, the flag
bits and the IEC tables moved with it into `debug-leaf-types.ts` so
neither module has to import the other.

Locals are surfaced only when the instance is retained. The debugger
keeps its black-box view everywhere else — that is the long-standing
contract, and it is what stops a project instantiating a few hundred
OSCAT blocks from growing a debug table several times its useful size.

An archive built before this format cannot be retained, and says so:
compiling `VAR RETAIN t : SomeOldLibFB` now fails with a message naming
the block and the fix. Silently keeping half of it is the behaviour this
commit exists to remove, and falling back to it on old input would leave
the same trap for anyone who has not rebuilt.

`retainBlobSize` joins the debug map so a build can be refused when the
target cannot hold the blob. It matters more now than it did: a retained
TON is 36 bytes, so a 512-byte baremetal cap is reached at about
fourteen of them.

Verified on an SLM-RP4. Two identical TONs, ten-second preset, differing
only in RETAIN; both left to elapse, then the program reloaded. Two
seconds later the retained one was still Q=TRUE ET=10s STATE=2 while the
other had restarted its wait at ET=3s160ms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012FNp61926UEggtmsQPj3A3
…ibrary-fb-locals

feat(retain): retain a library FB's internal state, not just its interface [NODE-94 phase 5]
Replaces the flattened `LibraryFBEntry.leaves` with a declarative
`LibraryFBEntry.locals`, in the same shape as `inputs` / `outputs` /
`inouts`. The consumer walks them with the walk it already uses for
user-defined blocks.

WHY THE FLATTENED FORM WAS WRONG
--------------------------------
It was built on a claim that does not survive contact with the data:
that a consuming compilation cannot name a library's internal members,
so the library must pre-compute every leaf. Measured across the five
bundled archives — 224 function blocks, 7,639 leaves — mangling applies
to exactly ZERO of them. The case is real but rare, and paying for it by
flattening everything was the wrong trade:

  ESR_COLLECT            773 leaves  ->  3 local declarations
  plcopen-softmotion     39% of the archive was leaf payload
  oscat-basic            324 KB of leaf payload
  all archives           7,639 leaves  ->  682 declarations

Every user paid that, in every project, whether or not they retained
anything. One entry now describes `buf : ARRAY[0..99] OF REAL` instead of
a hundred. plcopen-softmotion drops 632 KB -> 232 KB; oscat 2.2 MB ->
1.7 MB.

The rare case is carried, not guessed at: `LibraryVarType.cppName` holds
the mangled name when — and only when — the library's own codegen
produced one. Zero occurrences in the bundled archives; a user library
declaring `Tally : Tally` gets `cppName: "TALLY_"`, which is the CODESYS
pattern that made mangling necessary in the first place.

`leaf-walker.ts` and `debug-leaf-types.ts` are gone with it. They existed
so the library compiler and the debug table could share one walk; with
the library compiler no longer walking, there is one caller again, and a
shared module whose stated reason is false is worse than the duplication
it was preventing.

AN ARCHIVE WITHOUT LOCALS NO LONGER FAILS THE BUILD
---------------------------------------------------
It warns. Refusing would strand anyone using a third-party .stlib they
have no way to rebuild — a library installed from the catalogue is not
something the user can recompile. Retain covers the visible surface, and
the warning names the block and says what is missing, so a partial
retain is never silent.

A GAP THIS FOUND
----------------
Library struct types were registered with a self-referential
`TypeReference` as their AST definition, which the debug walk reads as
"opaque, do not descend". Harmless while those types were only
type-checked; wrong the moment a retained library block held one, since
the struct's fields never reached the blob and the instance restored
around a hole. Structs that export their fields now get a real
`StructDefinition`. Caught by a new test that compiles a library the way
a user would and retains an instance of its block — through a mangled
member, a library-internal struct, and a nested FB instance.

Output is unchanged where it matters: the same project produces the same
14 leaves, the same 54-byte blob and the same layout hash 618b1d38 as
the flattened design, and the SLM-RP4 restores identically — a retained
TON back at Q=True ET=10s STATE=2 two seconds after reload while its
un-retained twin restarts at ET=3s260ms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012FNp61926UEggtmsQPj3A3
…eclarative-locals

refactor(retain): describe library FB locals, don't pre-flatten them [NODE-94 phase 6]
…-table-gen

Review findings from PR #222:

- unpack() computed `len < HEADER_SIZE + payload` with payload read straight
  from the (untrusted, possibly corrupted) blob header. On a 16-bit size_t
  target (avr-gcc, the firmware this header is vendored into) that addition
  can wrap, letting a corrupted payload_len bypass the truncation check and
  send crc32() reading tens of KB past the real buffer. Compare via
  subtraction instead, now that len >= HEADER_SIZE is already established.
- Two JSDoc blocks in debug-table-gen.ts had drifted off the functions they
  documented (flagsLiteral's doc sat above applyBlockFlags;
  retainLayoutHashOf's sat above the unrelated RETAIN_HEADER_SIZE constant).
  Moved each back above its own function.
- retainLayoutHashOf(retainVars) was computed twice for the same input;
  hoisted into one local.
- Fixed indentation in debug-table-gen.test.ts where a describe block was
  nested one level deeper than its indentation implied.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D7txRxvUEhLirT4PDNM6Pf
…4-retain-variables

# Conflicts:
#	src/library/library-compiler.ts
…riables

feat(retain): CONSTANT, RETAIN and NON_RETAIN [NODE-94]
-Werror=implicitly-unsigned-literal is a Clang spelling; GCC has no
warning by that name and rejects it as an unknown option, aborting the
compile before it ever reaches the unsuffixed-literal check this test
exists to enforce. That has been failing 7 tests in this file on every
Linux CI run (including the last two development->main release merges,
v0.6.3 and v0.6.4) since the test predates this branch.

-pedantic-errors turns the same GCC diagnostic ("integer constant is so
large that it is unsigned") into a hard error and works on Clang too.

Verified in a Node 22 + GCC 13.3.0 / Ubuntu 24.04 container matching
the CI runner: the target test (7/7), the full suite (93 files, 2322
passed, 7 skipped, coverage gate green), lint and typecheck all pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ239CtwGVnDMSeLA2Lx93
…warning

fix(tests): use -pedantic-errors instead of a Clang-only warning name
@thiagoralves
thiagoralves merged commit 0b3b6df into main Aug 31, 2026
6 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.

2 participants