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:
| Step | Property | Written elsewhere? | Risk |
|---|---|---|---|
| 5, 3D placement | StandoffHeight | no, nowhere | highest |
| 5, 3D placement | Rotation on a body | other object types only | high |
| 2, pin edges | Symbol_OuterEdge, Symbol_InnerEdge | no, nowhere | high, and can stop the loop |
| 7, enum words | StrToPinElectrical | yes, Lib_AddPins | medium |
| 4, DNP paste | PasteMaskExpansion | yes, PCB_MakePasteGrid | low, but it edits the board |
| 3, filled body | AreaColor, IsSolid | yes, Generic.pas | low |
| 5, 3D placement | MoveByXY | yes, PCB_ReplicateLayout | low |
| 6, mirrored text | MirrorFlag | yes, PCB.pas | lowest |
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.
| Step | What must be open, or on disk |
|---|---|
| 0, 1 | Altium running with Altium_API loaded and the loop started |
| 2 | a SchLib, with a component selected |
| 3 | a SchLib |
| 4 | a PCB whose active variant has Not-Fitted parts |
| 5 | a PcbLib with a footprint, plus a .step file |
| 6 | a PcbLib, plus a KiCad .kicad_mod carrying B.SilkS text |
| 7 | a 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:
fault | What it means | Fix |
|---|---|---|
dead_loop | no response and no heartbeat | dismiss any Altium error dialog, Stop, relaunch |
stuck_handler | keep-alives answered, one command never returned | Stop, relaunch |
corrupt_response | Altium crashed mid-write | retry 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/Exceptcannot reach, and the polling loop stops. Recover with Detach andStartMCPServer.
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.