Skip to content

feat(located): support ARRAY at a physical address - #229

Merged
thiagoralves merged 2 commits into
developmentfrom
feature/gh-565-located-arrays
Sep 10, 2026
Merged

feat(located): support ARRAY at a physical address#229
thiagoralves merged 2 commits into
developmentfrom
feature/gh-565-located-arrays

Conversation

@JulioSergioFS

Copy link
Copy Markdown
Contributor

Resolves the compiler half of openplc-editor#565 ("Runtime compile error when using array type of Holding Registers").

  • Accept a located 1-D array of an elementary type. The issue was answered upstream with "MatIEC does not support located variables on non-base types" — true then, but that constraint left with MatIEC. Nothing here stands in the way: the descriptor table is flat, one {area, size, index, pointer} row per slot, so an array is N rows rather than a new mechanism. HR AT %MW60 : ARRAY [0..66] OF WORD now expands to 67 descriptors over %MW60..%MW126.
  • Detect address collisions by range overlap, not equality. An array occupies slotCount consecutive slots, so x AT %MW60 : ARRAY [0..66] OF WORD collides with a plain y AT %MW61 : WORD even though the two addresses differ. Comparing addresses for equality — all that was needed while every declaration took one slot — would have let the second variable silently share storage with an element of the first. Kept per size class: %MW0 and %MD0 index different runtime arrays and still do not collide.
  • Fix the pointer initialiser, which is a bug on its own. It located each descriptor with findIndex matching on variable name. With N descriptors sharing one name that resolves every element to slot 0 — element 0 bound N times, elements 1..N-1 left null. Now walks the array by position; the index is right there.

Rejections that stay, with their own message

Multi-dimensional, variable-length (ARRAY [*]), and non-constant bounds have no linear run of addresses to occupy. Each is refused with a sentence saying so, rather than falling through to the type-compatibility error — which is what leaked the internal __INLINE_ARRAY_WORD / __VLA_1D_WORD spelling at the user in the first place. A mismatched element type now reads Array element type 'BOOL' is not compatible with address size 'W', naming the half of the declaration that is actually wrong.

Verification

  • tests/semantic/located-variables.test.ts: 73 green, 15 of them new (element-type validation, named ARRAY TYPE, range collisions, the rejected shapes, bit arrays crossing a byte boundary, one descriptor per element with distinct pointers).
  • Full suite: 1349 passed, 0 failed (tests/semantic + tests/backend + tests/integration), tsc --noEmit clean, no new lint warnings (111 before and after).
  • End to end, compiling and running the generated C++ against the issue's own declaration:
    count=67 bound=67 distinct=67
    range=%MW60..%MW126
    elem3=0xBEEF elem4=0x0000
    
    distinct=67 is the line that pins the findIndex fix — before it, all 67 descriptors pointed at element 0.

Downstream

The editor accepts a located array in its variables table only once it bundles a release containing this. The paired editor PR carries that UI change already, so this needs to be released and the editor's binary-versions.json pin bumped before that PR merges — otherwise the editor accepts the declaration and the bundled v0.6.4 rejects it at compile time.

🤖 Generated with Claude Code

`HR_myData AT %MW60 : ARRAY [0..66] OF WORD` was rejected. The reason
given upstream was that MatIEC could not express a located non-elementary
type (openplc-editor#565) -- but that constraint left with MatIEC, and
nothing in this compiler stands in its way: the descriptor table is flat,
one {area, size, index, pointer} row per slot, so an array is N rows
rather than a new mechanism.

Analyzer: validate the ELEMENT type against the address size class, and
detect duplicates by slot-range overlap instead of address equality -- an
array occupies slotCount consecutive slots, so `x AT %MW60 : ARRAY[0..66]
OF WORD` collides with a plain `y AT %MW61 : WORD` even though the two
addresses differ. Reject the array shapes with no linear layout
(multi-dimensional, variable-length) with their own sentence, so none of
them falls through to the type-compatibility error and reports the
internal `__INLINE_ARRAY_<T>` spelling at the user.

Codegen: expand a located array into one descriptor per element, walking
the address forward by one slot each time (bit addresses continue across
the byte boundary).

Also fix the pointer initialiser, which located each descriptor by
variable NAME. With N descriptors sharing one name that resolved every
element to slot 0: element 0 bound N times, elements 1..N-1 left null.
Walk the array by position instead -- the index is right there.

Verified end to end: the issue's own declaration compiles to 67
descriptors over %MW60..%MW126, all 67 bound to distinct storage, and a
write through one descriptor lands in that element alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JulioSergioFS pushed a commit to Autonomy-Logic/openplc-editor that referenced this pull request Sep 2, 2026
…llisions

Editor half of openplc-editor#565. The compiler half is Autonomy-Logic/STruCpp#229.

**Accept the declaration.** `HR_myData AT %MW60 : ARRAY [0..66] OF WORD` was
refused: location validation switched on the variable's own type, and an array
fell through to the default branch. It now validates the ELEMENT type against
the address class, which is what actually has to fit -- 67 consecutive WORD
slots, each a WORD. The refusal came from the MatIEC era, and that constraint
left with MatIEC.

**Detect the collisions that come with it.** An array is a contiguous area, so
`arr AT %QX0.0 : ARRAY [0..9] OF BOOL` runs through `%QX1.1` and conflicts with
a plain `flag AT %QX0.6` -- two different address strings, one piece of storage.
`checkIfLocationExists` compared locations for string equality, which was
sufficient while every variable claimed exactly one slot and silently wrong the
moment one could claim more. It now compares slot RANGES, via a new
`slotRangesOverlap` beside `parseAddress`, reusing the existing
`getArrayTotalElements` for the span.

Two properties preserved deliberately: ranges only collide within the same
class (`%MW0` and `%MD0` index different runtime arrays and still never
overlap), and a location that is not a literal `%…` is an alias name, where the
test stays exact equality.

**Auto-increment follows.** The next-free-location scan tested set membership
against exact strings, so it could stop one slot inside a neighbouring array,
or place a new array on top of an existing scalar. It now advances until the
candidate's whole span is clear, and steps by the element type -- an array's
own `type.value` is the "ARRAY [...] OF T" text, which `incrementLocationByOne`
matches against nothing and would have bailed on the first pass.

Also fixes `variableLocationValidationErrorMessage` returning `''` for any type
with no address class, which surfaced as a bare "Please make sure that the
location is valid." with no reason attached.

The bundled compiler is still pinned to strucpp v0.6.4, which rejects a located
array. STruCpp#229 has to be released and `binary-versions.json` bumped before
this reaches users, or the editor accepts a declaration the build then refuses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@thiagoralves thiagoralves left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed together with openplc-editor#1080 and openplc-web#730 (which is a byte-identical mirror of #1080). Built this branch merged with development, ran the full suite (1021 passed, 0 failed), compiled real ST projects with g++ -fsyntax-only, and took the compiler end-to-end through the editor onto hardware (SLM-RP4 @ 192.168.2.4, runtime v4.2.0).

Verified working: inline located arrays, %QX bit arrays crossing byte boundaries, negative lower bounds, and CONFIGURATION VAR_GLOBAL arrays all generate correct per-element descriptors with distinct pointers. The end-to-end editor build produced 19 correct descriptors (10 HR + 8 Coils + 1 Counter), each bound to a distinct element. The findIndex → positional-index fix in generateLocatedVarPointerInit is correct, and locatedGlobals[] indexing stays consistent with generateLocatedGlobalsDefinition's filter. Rejection messages for multi-dimensional, VLA, empty-bounds and element-type mismatches are accurate and no longer leak __INLINE_ARRAY_* / __VLA_*.

One regression found — see the inline comment.

The stated merge order (release this PR, bump binary-versions.json, then merge the editor PRs) is correct and still necessary: the currently pinned v0.6.6 rejects located arrays.

Comment thread src/backend/codegen.ts
// Inline `ARRAY [a..b] OF T`: the AST builder resolves the bounds onto
// the TypeReference. A named ARRAY type carries none here and is
// handled by the model path, which resolves the alias first.
decl.type.arrayDimensions,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH — regression: named ARRAY types reach codegen and fail there.

collectLocatedVar passes decl.type.arrayDimensions, which is undefined for a named ARRAY type, while the analyzer's resolveLocatedShape resolves named types through resolveArrayShapeByName. The two disagree.

TYPE Buf : ARRAY [0..9] OF WORD; END_TYPE
VAR_GLOBAL HR AT %MW60 : Buf; END_VAR

This passes semantics and correctly reserves 10 slots (a second variable at %MW65 is properly rejected), but codegen emits one descriptor and calls HR.raw_ptr() on an array:

error: no member named 'raw_ptr' in 'strucpp::IEC_ARRAY_1D<strucpp::IECVar<unsigned short>, strucpp::ArrayBounds<0, 9>>'

On development the analyzer rejected this cleanly with Type 'Buf' is not compatible with address size 'W'. It now fails in the C++ backend leaking internal type names — the exact failure mode this PR set out to remove.

The PR's own test accepts the same shape spelled as a named ARRAY type only asserts compile(source).success and has no CONFIGURATION, so codegen's located path is never exercised. Suggest resolving the named type here the same way resolveLocatedShape does, and extending that test with a CONFIGURATION block.

@thiagoralves

Copy link
Copy Markdown
Contributor

Hardware validation (SLM-RP4 + P1AM-100)

Followed up the review with end-to-end runs on real hardware, using this PR's compiler injected into openplc-editor#1080. Summary: the located-array feature works correctly on both targets, and the named-ARRAY-type defect is confirmed as a regression that reaches the device.

✅ Located arrays work end-to-end

Project with HR AT %QW0 : ARRAY [0..9] OF INT, Coils AT %QX10.0 : ARRAY [0..7] OF BOOL, Counter AT %QW20 : INT, flashed to an SLM-RP4 (runtime v4.2.0) and read back over Modbus TCP — not just inspected in the generated source:

Holding registers 0..24        Coils 80..87 = 0b01010101
  HR[0] = 1000  ✓                coil 80 (Coils[0]) = True   ✓
  HR[1] = 1001  ✓                coil 81 (Coils[1]) = False  ✓
  ...                            ...
  HR[9] = 1009  ✓                coil 86 (Coils[6]) = True   ✓
  reg 10..19 = 0  (untouched)    coil 87 (Coils[7]) = False  ✓
  reg 20 = 623 (Counter, incrementing)

Each element lands on its own register/bit, the array claims exactly its 10 registers and no more, and the bit array is placed correctly within the byte. The same shape (ARRAY [0..3] OF INT at %QW0, ARRAY [0..7] OF BOOL at %QX2.0) also compiled through arduino-cli and flashed to a P1AM-100, reading back Words[0..3] = 500..503 and the alternating bit pattern. A scalar-only control project (%QW0, %QW1, %QX0.0, %QX0.1) still reads back correctly, so the classic path has no regression.

❌ The named-ARRAY-type regression reaches the device

Same project with HR retyped to a named TYPE Buf : ARRAY [0..9] OF INT. It is worse than I described in the inline comment — it is not only a codegen crash, it is also a silent under-allocation:

LocatedVar locatedVars[10] = {                          // ← 10, should be 19
    { LocatedArea::Output, LocatedSize::Word, 0, 0, ... },  // HR AT %QW0   ← ONE descriptor for 10 elements
    ...

HR[1..9] get no descriptor at all, so they would silently never bind to %QW1..%QW9. It then fails to build, on the device, after the editor has already reported the ST→C++ stage as successful:

error: core/generated/configuration.cpp:55:39: error:
  'class strucpp::IEC_ARRAY_1D<strucpp::IECVar<short int>, strucpp::ArrayBounds<0, 9> >'
  has no member named 'raw_ptr'
error: 55 |     locatedVars[0].pointer = HR.value.raw_ptr();
error: make: *** [scripts/Makefile.strucpp:131: build/configuration.o] Error 1
PLC program has not been updated because the build failed

Confirmed as a regression against the currently pinned release — v0.6.6 rejects the identical source cleanly at the ST level:

named.st:34:5: error: Type 'BUF' is not compatible with address size 'W' in '%QW0'.
                      Expected one of: WORD, INT, UINT
  34 |     HR AT %QW0 : Buf;

So the PR turns a precise, actionable ST diagnostic into a C++ template error surfaced from the target's build log — the exact failure mode it set out to remove. Worth fixing before release, since binary-versions.json has to be bumped to this build for the editor PRs to work at all.

Regression found in review. The AST builder writes bounds onto the
TypeReference only for an INLINE `ARRAY [a..b] OF T`; a named type carries
none. `collectLocatedVar` read `decl.type.arrayDimensions` directly, while the
analyzer's `resolveLocatedShape` resolves named types through
`resolveArrayShapeByName` -- so the two passes disagreed:

    TYPE Buf : ARRAY [0..9] OF WORD; END_TYPE
    VAR_GLOBAL HR AT %MW60 : Buf; END_VAR

Semantics accepted it and correctly reserved ten slots (a second variable at
%MW65 was properly rejected), then codegen emitted ONE descriptor and called
`.raw_ptr()` on the array itself:

    error: no member named 'raw_ptr' in 'IEC_ARRAY_1D<IECVar<unsigned short>, ArrayBounds<0, 9>>'

Worse than the state before this branch, where the analyzer refused the
declaration cleanly -- it failed in the C++ backend leaking internal type
names, which is the failure mode this branch set out to remove.

Codegen now resolves the shape by name when the declaration carries no bounds,
mirroring what the analyzer already does.

The existing test missed this because it asserted only `compile(source).success`
with no CONFIGURATION, so codegen's located path never ran. Replaced with one
that emits the path and checks what comes out: ten descriptors over
%MW60-%MW69, ten distinct pointers, and no `.raw_ptr()` on the aggregate.
Verified the generated C++ now passes `g++ -fsyntax-only`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@JulioSergioFS

Copy link
Copy Markdown
Contributor Author

Thanks — the named-ARRAY regression is real and it was mine. Fixed in b2d5a1a.

Cause, as you diagnosed: the AST builder writes bounds onto the TypeReference only for an inline ARRAY [a..b] OF T. collectLocatedVar read decl.type.arrayDimensions straight, while the analyzer resolved named types through resolveArrayShapeByName — so semantics reserved ten slots and codegen emitted one descriptor, then called .raw_ptr() on the aggregate.

Codegen now resolves the shape by name when the declaration carries no bounds, through the same resolveArrayShapeByName the analyzer uses, so the two passes cannot drift again. Both collectors (collectLocatedVar and collectLocatedVarFromModel) route through one helper, so the project-model path is covered too.

On the weak test — you were right, and that is why it passed. Asserting compile(source).success with no CONFIGURATION meant codegen's located path never ran. Replaced with one that emits the path and checks what comes out: ten descriptors over %MW60%MW69, ten distinct pointers, and no .raw_ptr() on the array.

Verified after the fix:

  • g++ -fsyntax-only on the generated C++ for your exact TYPE Buf : ARRAY [0..9] OF WORD case — clean, 10 descriptors.
  • Named-type edge cases still rejected in semantics, so the fix did not open a hole:
    • TYPE Grid : ARRAY [0..3, 0..3] OF WORDa 2-dimensional array has no single linear run of addresses to occupy
    • TYPE Bits : ARRAY [0..3] OF BOOL at %MW0Array element type 'BOOL' is not compatible with address size 'W'
  • Full suite: 1350 passed, 0 failed.

Worth flagging: my verification here is g++ -fsyntax-only plus the suite. That is weaker than what you did — this has not been on a board since the fix. If you have the SLM-RP4 set up, the named-type case is the one worth re-running.

One consequence to note: the compiler now accepts a named ARRAY type at a location, and the editor still refuses it (a user-data-type has no address class there). That is the safe direction — editor-rejects / compiler-accepts — so it is a capability the UI does not expose rather than a divergence that breaks builds. Say the word if you want the editor to reach it too and I will open it separately.

Also corrected the pin in openplc-editor#1080: it said v0.6.4, it is v0.6.6 as you wrote. My local node_modules was stale and I trusted it over binary-versions.json.

@thiagoralves

Copy link
Copy Markdown
Contributor

Re-tested on hardware after b2d5a1a — regression is fixed ✅

Rebuilt the branch (merged with development), re-injected it into openplc-editor#1080 at bb0ec38, and re-ran the full hardware suite.

The named-ARRAY-type case now behaves exactly like the inline one. Same project as before, HR typed as TYPE Buf : ARRAY [0..9] OF INT:

LocatedVar locatedVars[19] = {                          // was 10
    { LocatedArea::Output, LocatedSize::Word, 0, 0, ... },  // HR[0] AT %QW0
    { LocatedArea::Output, LocatedSize::Word, 1, 0, ... },  // HR[1] AT %QW1
    ...                                                     // HR[9] AT %QW9
    locatedVars[0].pointer = HR.value[0].raw_ptr();         // was HR.value.raw_ptr()

Flashed to the SLM-RP4 and read back over Modbus TCP — holding registers 0..9 = 1000..1009, registers 10..19 untouched, register 20 the incrementing counter, coils 80..87 = 0b01010101. Byte-for-byte the same image the inline ARRAY [0..9] OF INT produces.

No new regressions

Check Result
strucpp suite 2336 passed, 0 failed (93 files)
Inline located arrays → SLM-RP4, Modbus read-back ✅ unchanged
Named ARRAY type → SLM-RP4, Modbus read-back ✅ now correct
Scalar-only control project → SLM-RP4 %QW0, %QW1, %QX0.0, %QX0.1 all correct
P1AM-100 (arduino-cli → flash → debug read) Words[0..3] = 500..503, alternating bits, ticking
ARRAY [-3..2] negative lower bound ✅ 6 descriptors

Rejection paths still clean and precise, with no internal type names leaking:

Located variable 'MD' at %MW0 cannot be placed: a 2-dimensional array has no single
  linear run of addresses to occupy. Declare it unlocated, or use a one-dimensional array.
Duplicate address %MW65: variable 'X' conflicts with 'HR' (HR occupies %MW60 onwards)
Array element type 'REAL' is not compatible with address size 'W' in '%MW0'.

The overlap message is correct through a named type too (Buf at %MW60 still reserves ten slots, so %MW65 is refused) — the analyzer and codegen now agree in both directions.

Probed the new resolveLocatedDims for edge cases

TYPE Grid : ARRAY [0..3,0..3] (multi-dim named), TYPE Pt : STRUCT ..., TYPE MyWord : WORD (scalar alias), and ARRAY [0..4] OF Inner where Inner : WORD (array of an aliased element). All four are caught in semantics with a clear message and never reach codegen, so the new by-name resolution has no path that produces a wrong descriptor.

Two of those — the scalar alias MyWord at %MW0, and ARRAY [0..4] OF Inner — are refused even though a type alias to WORD arguably ought to be placeable. I checked both against v0.6.6 and they are rejected there too, so that is pre-existing and not something this PR introduced. Worth a separate issue if aliases should be transparent to located placement; this branch actually improves the second message (it now names the element type as the problem rather than the whole type).

Nothing further blocking from my side.

@thiagoralves

Copy link
Copy Markdown
Contributor

Concurrency review + multi-threaded hardware test

Checked whether located arrays respect the threading model, then ran a genuinely multi-task project on an SLM-RP4 (runtime v4.2.0). Verdict: the feature respects every existing concurrency boundary. No new shared mutable state, no new races. One limit is worth documenting explicitly, plus two pre-existing scaling notes.

Why it's safe: an array is N independent located scalars

Both halves of the protection apply per element, unchanged:

Program side — every element access on a located global goes through the per-global mutex. From the generated code:

for (I = 0; I <= 9; I++) {
    auto __gwv_0 = FASTTICK->read();
    FASTARR->with_lock([&](auto* __glk){ (*__glk).at(I) = __gwv_0; });
}

The value is computed outside the lock and only the store is inside, so no lock is ever nested with another global's — the deadlock-freedom argument in iec_global.hpp still holds.

Dispatcher sideimage_tables_copy_config_globals_out() is called only at the quiescent frame boundary (g_tasks_running == 0) under image_lock(). All N descriptors of an array are copied inside that one window, so the image never shows a half-updated array. Under overrun the copy-in is skipped rather than racing, exactly as for scalars.

Three more things I checked specifically because arrays could have broken them:

  • Bit arrays don't share a byte. ARRAY [0..7] OF BOOL AT %QX10.0 is 8 descriptors all naming byte 10. That would be a lost-update hazard if the image packed bits, but it stores each bit in its own slot (bool_output[idx][bit] is a pointer per bit), so there is no read-modify-write.
  • Pointer-identity classification scales. located_globals_join_ex() matches on address, and every element has a distinct one. The runtime logged 34 located var(s) ... (0 program-local, 34 config-scope shared globals) with no "unmatched" or "double-serviced slot" error.
  • The dirty-diff snapshot is per element, so a task that only reads a shared output array never clobbers a concurrent writer.

Hardware test

Two tasks, deliberately near-equal rates for maximum interleaving:

TASK fast(INTERVAL := T#5ms,  PRIORITY := 1);   -- writes FastArr[0..9] all = tick, Shared[0..4]
TASK slow(INTERVAL := T#10ms, PRIORITY := 2);   -- writes SlowArr[0..9], Shared[5..9], reads FastArr

FastArr gets the same value in all ten elements each scan, so any non-quiescent copy-out would show up as mixed values in the Modbus image. 1200 Modbus samples over ~5000 fast scans:

Check Result
FastArr image coherent (all 10 registers equal) 0 failures / 1200
SlowArr image coherent 0 failures / 1200
Shared[] written by two different tasks (0-4 fast, 5-9 slow) 0 wrong / 1200
Runtime errors / journal drops / overrun warnings none

Forcing also behaves under contention: forcing Config0:FastArr[3] = 12345 pinned that one element across 300 samples while the 5 ms task rewrote all ten every scan, and the other nine stayed live and coherent with each other.

The one limit worth stating in the docs

Per-access validity, not whole-array atomicity. with_lock is taken per element, so writing arr[0..9] in a FOR loop is ten separate critical sections and another task can interleave. Measured directly: the slow task read FastArr while the fast task wrote it and observed a partially-updated array in 82 of 2484 reads (3.3%).

This is the documented GlobalVar contract ("per-access validity ... conflicts resolve last-writer-in-time") and is not new — a non-located VAR_GLOBAL array behaves identically. But located arrays make it much easier to reach, because the natural way to use one is a loop over all elements. Anyone needing a coherent multi-element snapshot has to add their own handshake (a sequence counter or valid flag). Worth one sentence in the located-array docs so it is a documented property rather than a surprise. Note the image is unaffected — the quiescent copy means Modbus/plugin readers always see a coherent array; only a concurrent IEC task can observe the tear.

Two pre-existing scaling notes (not regressions — flagging because arrays make them easy to hit)

  1. A range past the end of the image is accepted silently. ARRAY [0..99] AT %QW1000 compiles and emits 100 descriptors spanning %QW1000-%QW1099, but the runtime image is BUFFER_SIZE 1024. The runtime bounds-checks (apply_write_raw drops idx >= buffer_size), so it is memory-safe — but 76 elements silently never sync, with no diagnostic from the editor or the compiler. I confirmed a plain scalar at %QW1500 is equally accepted by v0.6.6, so the gap predates this branch; arrays just turn one obviously-wrong address into a plausible-looking declaration. An upper-bound check on the end of a located range would close it.
  2. Journal pressure scales with element count. The journal holds 4096 entries per cycle per bank and drops (with a reported count) beyond that. Only changed elements are journaled thanks to the dirty-diff snapshot, so this needs thousands of elements changing per scan — but a few large located arrays could reach it where scalars never would.

Neither blocks this PR.

@thiagoralves
thiagoralves merged commit 413341f into development Sep 10, 2026
@thiagoralves
thiagoralves deleted the feature/gh-565-located-arrays branch September 10, 2026 19:10
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