Release verification: 2026.08.08.10

August 8, 2026 ยท View on GitHub

Everything below is Pascal that FPC and the linter have checked and that Altium's DelphiScript engine has never executed. The two are not the same: each accepts identifiers the other rejects, and an undeclared one faults at runtime where Try/Except cannot catch it, halting the polling loop.

Work top to bottom. Step 1 needs nothing but Altium and takes seconds, and is the step most likely to catch a compile-level problem. If it fails, stop and fix that before running the rest.

If there is only time for some of it, the risk is not evenly spread. What separates the steps is whether the Altium property being written is already written somewhere in shipped code, because a property that works elsewhere cannot be an undeclared identifier:

StepPropertyWritten elsewhere?Risk
5, 3D placementStandoffHeightno, nowherehighest
5, 3D placementRotation on a bodyother object types onlyhigh
2, pin edgesSymbol_OuterEdge, Symbol_InnerEdgeno, nowherehigh, and can stop the loop
7, enum wordsStrToPinElectricalyes, Lib_AddPinsmedium
4, DNP pastePasteMaskExpansionyes, PCB_MakePasteGridlow, but it edits the board
3, filled bodyAreaColor, IsSolidyes, Generic.paslow
5, 3D placementMoveByXYyes, PCB_ReplicateLayoutlow
6, mirrored textMirrorFlagyes, PCB.paslowest

Steps 5 and 2 are the ones that justify a live session. The bottom rows write properties this codebase already exercises, so they are checking the new call site rather than the API.

Step 5 appears three times because its properties do not share a risk. MoveByXY is inherited from IPCB_Primitive and PCB_ReplicateLayout already calls it, so it cannot be an undeclared identifier and a failure there would be behavioural: whether moving a group moves its children. Rotation is written on pads, texts, fills and components but never on a body, and DelphiScript resolves a property against the object in hand, so another interface accepting it proves nothing here. Only StandoffHeight is entirely unexercised.


Gather these before you start

Each step wants a particular document open or a particular file on disk, and none of it is interesting to discover halfway through. Step 5 in particular needs a STEP model, which is not something to go looking for mid-session.

StepWhat must be open, or on disk
0, 1Altium running with Altium_API loaded and the loop started
2a SchLib, with a component selected
3a SchLib
4a PCB whose active variant has Not-Fitted parts
5a PcbLib with a footprint, plus a .step file
6a PcbLib, plus a KiCad .kicad_mod carrying B.SilkS text
7a SchLib for the pins, and a schematic sheet for the power ports

Steps 4 and 5 write to the design. Take an app_checkpoint first, or work on a copy. Everything else either reads or builds new library objects you can delete afterwards.


0. Confirm what is actually loaded

app_ping

Expect altium_script_version = 2026.08.08.10, version_match = true, and mcp_server_version = 0.5.0.

Those are two different versions and they fail differently. altium_script_version is the Pascal that Altium compiled; mcp_server_version is the Python package answering the call. A wrong Pascal version means a stale deploy. A wrong package version means the installed wheel is not this tree, so the tools themselves differ from what this document describes.

A mismatch means Altium is still running an older compiled copy. Reload the script project (File > Run Script, pick the project, or close and reopen it) and ping again. Do not interpret any later result until this matches: a stale script produces failures that look like defects in the new code.

No answer at all is a different failure with a different fix. A mismatch means the wrong script is running; a timeout usually means no script is running. Altium's process being alive proves nothing here, since the polling loop can be stopped while Altium itself is fine. That was the state of this machine while this document was written.

Read last_fault.json in the workspace directory rather than guessing. The bridge writes a diagnosis and numbered steps there, and tells the three cases apart:

faultWhat it meansFix
dead_loopno response and no heartbeatdismiss any Altium error dialog, Stop, relaunch
stuck_handlerkeep-alives answered, one command never returnedStop, relaunch
corrupt_responseAltium crashed mid-writeretry once, the bad file is already removed

Stop is the red button in the Script IDE (Run > Stop, Ctrl+F3, or Ctrl+Pause if it is truly hung). Relaunch is File > Run Script... > Altium_API > Dispatcher.pas > StartMCPServer.

Leftover request_*.json files in the workspace are a symptom of this, not a cause: they are calls the loop never picked up. They are harmless and the next healthy loop consumes them.


1. Pure logic, no document needed

File > Run Script... > SelfTest > RunSelfTest

Expect Failed: 0. The log is written to the workspace directory.

This runs 27 assertions over StrToIeeeSymbol, IeeeSymbolToStr and StripChar inside Altium's own engine. The values match what tests/test_cross_validate.py pins against the FPC-compiled originals, so a failure here means DelphiScript disagrees with Free Pascal. That is the gap this step exists to close.

If it fails: the log names the assertion. Report the text; the converters are in scripts/altium/Utils.pas.


2. Pin edge decorations (task #20)

Needs a SchLib open with a component selected.

lib_add_pins(pins=[
  {"designator": "1", "name": "RESET", "x": -300, "y": 0,
   "symbol_outer_edge": "dot"},
  {"designator": "2", "name": "CLK",   "x": -300, "y": -100,
   "symbol_inner_edge": "clock"},
])

Then look at the symbol. Pin 1 must carry an inversion bubble at its outer end; pin 2 a clock wedge at the body end.

The returned added count says how many pins were created, not how they were drawn, so it cannot confirm this. Render or view it.

What is actually being tested: whether Symbol_OuterEdge / Symbol_InnerEdge are settable on a pin from SchObjectFactory before AddSchObject, and whether assigning an Integer to those enum-typed properties behaves the way Pin.Orientation does. Step 1 already proved the value mapping.

The two failure shapes, and why the assignment is deliberately not wrapped in Try/Except:

  • the property exists but rejects the value: the dispatcher catches it and answers INTERNAL_ERROR: Unhandled exception processing: library.add_pins. The loop keeps running.
  • the property name is not real: an undeclared identifier faults where Try/Except cannot reach, and the polling loop stops. Recover with Detach and StartMCPServer.

Guarding the assignment would turn both into a silent success that adds undecorated pins, which is the one outcome this step could not tell apart from working. That is also why this step is early: it is the first thing here that can halt the loop.


3. Filled IC bodies (task #28)

lib_create_ic_symbol(...)          # any part

The body rectangle must be filled Altium light-yellow, not a bare outline. This is discipline rule 17, and it has been silently unmet: the tool sent AreaColor 8454143 and the bridge discarded it because IsSolid was pinned False.

Then confirm the no-fill path still works:

lib_create_passive_symbol(kind="resistor", ...)

That sends fill_color=-1, the documented no-fill sentinel, and its body must stay unfilled. -1 is also the parameter's default, so it arrives on nearly every call; treating it as a colour would turn every symbol rectangle solid.


4. DNP paste exclusion (task #26)

This edits the board. Work on a copy, or app_checkpoint first.

Needs a PCB with a variant that has Not-Fitted parts.

pcb_apply_dnp_paste_exclusion(dry_run=True)

Check the designator list matches audit_variant_not_fitted. Then:

pcb_apply_dnp_paste_exclusion()
pcb_get_pad_properties(designator="<one of them>")

paste_mask_expansion must be negative.

A negative expansion is necessary but not sufficient. Only the artwork proves the fab outcome:

proj_generate_fab_package(...)

Open the paste layer and confirm those apertures are absent. Then restore, passing back the designators the apply reported in items:

pcb_apply_dnp_paste_exclusion(designators=["<from the apply reply>"],
                              restore=True)

Regenerate and confirm the apertures come back.

Restore will refuse a bare restore=True, and that refusal is the behaviour to verify, not a defect. Resolving a restore from the current variant is only correct while the variant has not changed since the apply; when it has, a component excluded under the old variant keeps its aperture suppressed and nothing says so. Check that the refusal names both the way forward and use_current_variant, which is the explicit opt-in to the old resolve-from-the-variant behaviour.

After a restore, paste_mask_expansion reads 0, which is what a successful restore looks like and not a separate failure. The field is always present; PCB_GetPadProperties initialises it to 0 and only substitutes the real value when the pad cache is manual. Restore sets the cache back to invalid, so the design rule drives the aperture and the reported number falls to 0.

A pad that was never excluded also reads 0, so this distinguishes applied from not-applied, not restored from never-touched. The artwork is what settles that.

Check a bottom-side DNP part too if the board has one: Pad.TopXSize is the top-layer size and drives the expansion for both sides.


5. 3D model placement (task #27)

The highest-risk item. All three properties are documented on IPCB_ComponentBody, but only MoveByXY has ever been exercised here, and on a different object type. See the note under the risk table for which of the three is actually unproven.

lib_link_3d_model(component_name="<fp>", model_path="<...>.step",
                  offset_z=40, rotation_z=90, offset_x=10)

Read the applied object in the reply first: standoff_height, rotation_z, offset_xy. Each assignment is individually guarded, so one property failing does not fail the call.

All three must read true for the call above. They mean "this was applied", not "this was accepted": the handler skips a property whose value is 0, so a false is either a rejection or a value you did not pass. Keep every argument non-zero while verifying, or the two cases are indistinguishable. offset_xy covers offset_x and offset_y together and reads true if either is non-zero.

Then open the 3D view: the body should be lifted, turned and nudged.

Units: the tool documents mils and applies MilsToCoord. A body that moves 25.4x too far means the property wanted different units.

rotation_x / rotation_y are accepted and deliberately not applied, because the API gives the body a planar rotation only. If a live session finds an X/Y tilt property, revisit the docstring then.


6. Mirrored bottom-side text (task #21)

Lowest risk of the set. MirrorFlag is already written by PCB.pas and read by Audit.pas; only the call site is new.

Import a footprint carrying B.SilkS text, then:

audit_find_mirrored_pcb_text

Expect zero violations. Before this change the importer produced bottom_overlay_text_is_not_mirrored on every such item, i.e. output this server's own audit rejected.


7. Enumerated words map to the right Altium enum (task #33)

This covers what tests/test_enum_vocabularies.py cannot. That test proves every advertised spelling has a branch in the StrTo* converter. It cannot prove the branch assigns the enum member the word names, because the ordinals only exist inside Altium.

The failure is silent. Each converter ends in an Else that picks a default, so a wrong or missing branch yields Passive, or a supply Bar, with no error reported.

Place four pins with different electrical types, then read them back:

lib_add_pins(pins=[
  {"designator": "1", "name": "VIN",  "x": -300, "y":    0,
   "electrical_type": "power"},
  {"designator": "2", "name": "NRST", "x": -300, "y": -100,
   "electrical_type": "open_collector"},
  {"designator": "3", "name": "SDA",  "x": -300, "y": -200,
   "electrical_type": "io"},
  {"designator": "4", "name": "OUT",  "x": -300, "y": -300,
   "electrical_type": "output"},
])
lib_get_component_details(...)

Each pin must report the type it was given. All four reading passive means the string never matched and every one took the default; open_collector alone reading passive means the underscore handling is the part that broke.

Then one power port per glyph:

sch_place_power_port(text="GND",  style="gnd_signal", x=1000, y=1000)
sch_place_power_port(text="+3V3", style="bar", x=1400, y=1000,
                     orientation=1)

orientation=1 on the rail is required. The style-based default sends bar and wave down with the grounds, so a VCC bar drawn without it points down and looks like a ground symbol.

Look at the sheet. A signal-ground glyph and a rail bar are visually distinct; two identical bars mean gnd_signal fell through to the ePowerBar default.


8. ERC violations name the objects they are about

proj_run_erc(), then proj_get_erc_violations()

On a project that reports violations, every entry should now carry related_objects, each with a kind, a document and a cross_probe string.

This is the step that decides whether the tool is usable at all. A violation reported as a category and a sheet name cannot be acted on: the only safe response to "floating input pin, somewhere on this sheet" is to do nothing, because a NoERC marker placed by guesswork silently suppresses a real disconnection and is worse than the warning it clears. cross_probe is what Altium itself uses to jump to an object, so it is what identifies the specific pin or net.

Compare against the Messages panel: the objects listed there for a given violation should match related_objects for the same index.

Expect related_object_count to be non-zero for floating-pin and unconnected-object violations. Zero across every violation means DM_RelatedObjects returned nothing in this build, which is a different outcome from the call faulting, and is why the count is reported separately from the list.

If it fails: the risk here is DM_PrimaryCrossProbeString. It is declared on IDMObject, the base every related object implements, so it should be safe on all of them, but that is reasoned from the reference rather than measured. An undeclared identifier faults where Try/Except cannot catch it and halts the polling loop, so a dead loop right after calling this tool points at that call. Gen_GetErcViolations is in scripts/altium/Generic.pas.


9. Reading a symbol's pins no longer faults (task #34)

Needs a SchLib open.

lib_get_pin_list(component_name="<a symbol in that library>")

Expect a pin list. Then call it again with no component_name and a symbol selected in the editor, which must also work.

This one is a fix for an observed crash, not a new feature. The deployed script answered Undeclared identifier: SchIterator_Create and stopped the polling loop. The identical call appears ten times in Library.pas and works everywhere else; the difference was where the component came from. Every working reader fetches it through GetState_SchComponentByLibRef or a SchLib iterator, while this one used the editor's CurrentSchComponent directly. DelphiScript narrows an interface at iterator-return, and a component obtained any other way does not carry the methods.

So this step is really asking one question: does resolving through the library make the iterator available? If it does, the explanation holds.

component_name is the other half of the fix. Reading a symbol's pins used to depend on, and disturb, whatever the editor had selected, which is why exporting one symbol could change which symbol later calls saw.

If it fails with the same identifier, the narrowing explanation is wrong. Say so rather than trying variations: that reasoning came from comparing call sites, not from proving the mechanism, and the next step would be to instrument rather than guess again.

If it fails with a different identifier, that is a second undeclared name in the same function and the message will say which.


Step 9: the multi-part scope suffix actually switches part

Reported in GH #11 against a 4-part TPS23881B, with numbers. The @N suffix parsed and reached the part-switch code, and the switch itself did nothing: Component.CurrentPartID := N takes the value, the editor's part spinner does not move, and the SchLib iterator follows the DISPLAYED part. So obj_query returned part 1's pins whatever the scope said, and with the spinner moved by hand the suffix was ignored outright. Nothing errored in either direction.

The fix drives the editor's own command, SCH:NextComponentPart, and reads GetState_CurrentSchComponentPartId back after each step. Both appear in two independent scripts under reference/, so neither is a guess, but neither has run from this codebase.

Open a multi-part SchLib and, with the editor showing part 1:

obj_query  scope lib_component:<NAME>@2  kind ePin
obj_query  scope lib_component:<NAME>@3  kind ePin

Each must return that part's own pin count, and every returned pin must carry the matching OwnerPartId. Then the sharper test: switch the spinner to part 3 by hand and query @1. It must return part 1.

Two ways this fails quietly. If GetState_CurrentSchComponentPartId is undeclared, the polling loop halts, which is loud. If it returns -1 instead, the stepping is skipped by design and the behaviour is exactly the bug being fixed: the same wrong answer, no error. So a run that still returns part 1 is not evidence the command is wrong, it is evidence the part id could not be read. Report which.

The loop is bounded by PartCount because the command wraps at the last part. A target that can never be reached leaves the editor moved but not where asked, so check the spinner afterwards.


10. UNC paths survive the trip (task #44)

This release deletes the vestigial second unescape (StringReplace(x, '\\', '\', -1)) from all 94 path-taking handlers. ExtractJsonValue already unescapes the JSON, so the second pass was a no-op for local paths and stripped one leading backslash from UNC paths: \\server\share\lib.SchLib arrived as \server\share\lib.SchLib and failed as a missing file.

No new identifiers are involved, only deletions, so the compile risk is nil; what needs proving is the behaviour. From a machine with any reachable share (an admin share like \\localhost\C$\... works):

lib_get_components  library_path \\localhost\C$\<path-to-any>.SchLib

Before the fix this fails with a file-not-found flavoured error; after it, the library opens and lists components. Local absolute paths must keep working unchanged, which step 9's queries already exercise.

tests/test_no_double_unescape.py pins the site count at zero from now on, so this is a one-time verification, not a recurring step.


Still open, and not blocking

  • #23 font size: the importer places symbol text but not its height. Altium's font size is not in mils and the conversion is undocumented, so the source range is reported rather than guessed. Calibrating it needs a live measurement.