Skip to main content

uplm80 - PL/M-80 Compiler

PyPI version Tests Pylint License: GPL v3

A modern PL/M-80 compiler targeting Zilog Z80 assembly language.

PL/M-80 was the primary systems programming language for CP/M and other 8080/Z80 operating systems. This compiler can rebuild original CP/M utilities from their PL/M source code.

Repository: https://github.com/avwohl/uplm80

Features

  • Full PL/M-80 language support
  • Targets Z80 instruction
  • Multi-file compilation with cross-module optimization
  • Multiple optimization passes (AST optimizer, upeepz80 peephole optimizer)
  • Generates relocatable object files compatible with standard CP/M linkers
  • Calls procedures the way Intel's PL/M-80 does, so its code links with assembly and objects written for PL/M-80
  • Produces code competitive with the original Digital Research compiler

Code Quality

Compiled output is comparable to the original Digital Research PL/M-80 compiler:

Program DR PL/M-80 uplm80 Difference
PIP.COM 7424 bytes 7127 bytes -4.0%

Installation

Quick install from PyPI:

pip install uplm80 um80 upeepz80

Platform-specific guides:

Or install from source:

git clone https://github.com/avwohl/uplm80.git
cd uplm80
pip install -e .

Usage

Compile PL/M-80 to Assembly

uplm80 input.plm -o output.mac

Or run as a module:

python -m uplm80.compiler input.plm -o output.mac

Options:

  • -m cpm, -m bare or -m mpm - Runtime mode (default: cpm)
    • cpm: For new PL/M programs, maximum stack under BDOS
    • bare: Original Digital Research compatible (jump to start-3)
    • mpm: MP/M II relocatable modules (.PRL, .RSP, .SPR)
  • -o output.mac - Output file name
  • -O 0|1|2|3 - Optimization level (default: 2)
  • -D SYMBOL - Define conditional compilation symbol (can be repeated)

Multi-File Compilation

Compile multiple source files together for optimal cross-module optimization:

uplm80 main.plm helper.plm library.plm -o output.mac

When multiple files are provided:

  • All files are parsed together before code generation
  • A unified call graph is built across all modules
  • Procedures that are never active at the same time share storage for their parameters and for the locals that may share it (??AUTO, see Procedure locals), across module boundaries
  • Calls between the modules are made as between modules compiled apart (see Calling Convention), so a module can as well be compiled alone and linked with the others
  • Each module keeps its own name space, as if it were compiled alone and linked: a name that is not PUBLIC or EXTERNAL is qualified with the module's name in the output (LIB?HELPER), and PUBLIC and EXTERNAL names bind across the modules (see docs/multi_file_compilation.md)
  • A single combined output file is generated

This produces better code than compiling files separately, as the compiler can share local variable storage between procedures in different modules that never call each other.

Use your preferred Z80 assembler and linker. Example with um80/ul80:

um80 output.mac                              # Assemble to .rel
um80 x0100.asm                               # MON1 equ 5 and the rest (see CP/M Mode)
ul80 -o program.com output.rel x0100.rel     # Link to CP/M .com

The module carries the runtime routines it uses (see Runtime Library); link with it only what defines the names it declares EXTERNAL, here x0100.rel for MON1.

Language Reference

PL/M-80 is a typed systems programming language with:

  • Data types: BYTE (8-bit), ADDRESS (16-bit)
  • Variables: Scalars, arrays, structures, BASED variables (pointers)
  • Control flow: DO/END, DO WHILE, DO CASE, IF/THEN/ELSE
  • Procedures: With parameters, local variables, recursion
  • Built-in functions: HIGH, LOW, DOUBLE, SHL, SHR, ROL, ROR, etc.
  • I/O: INPUT, OUTPUT for port access

Example:

hello: DO;
    DECLARE message DATA ('Hello, World!$');
    DECLARE i BYTE;

    mon1: PROCEDURE(func, parm) EXTERNAL;   /* mon1 equ 5: see CP/M Mode */
        DECLARE func BYTE, parm ADDRESS;
    END mon1;

    print: PROCEDURE(addr) PUBLIC;
        DECLARE addr ADDRESS;
        /* CP/M BDOS print string */
        CALL mon1(9, addr);
    END print;

    CALL print(.message);
END hello;

See examples/hellocpm.plm for a complete working example. A drop-in Makefile that drives the full uplm80 → um80 → ul80 pipeline (with optional ud80/ux80 disassembly targets) is available at docs/example.Makefile — contributed by Martin Homuth-Rosemann (@Ho-Ro, issue #5).

For more on CP/M BDOS usage, see docs/BDOS_REFERENCE.md.

The compiler holds a program to these rules of the Programming Manual (9800268B), as Intel's PL/M-80 V3.1 does, and says what it found and where:

  • Every name is declared, the built-ins aside (6.1); in a multi-file compile a module may name another's PUBLIC name without declaring it EXTERNAL, but for a built-in's name, which is the built-in in a module that does not declare it, as it is compiled alone.
  • Each parameter is declared once, by a DECLARE of its procedure, as a BYTE or an ADDRESS scalar, not BASED and with no other attribute (8.1.1).
  • A BASED variable's base is an ADDRESS scalar, a variable or a parameter, or an ADDRESS scalar member of a structure that is neither BASED nor an array, declared before the BASED variable, in its block or one around it, as V3.1 takes it; the base is that declaration wherever the BASED variable is used.
  • A LABEL declared in a block, not PUBLIC nor EXTERNAL, labels a statement of the block (9.3); a label of its name in a DO block of it is another label, the DO block's.
  • A declaration hides the built-in of its name (9.2): a procedure DOUBLE or a variable OUTPUT, MEMORY or STACKPTR that a module declares is the module's there, and the compiler never takes it for the built-in.
  • A LITERALLY's text takes its name's place throughout its scope, in the text after the declaration (6.4), a declaration of the name in an inner block included: after DECLARE N LITERALLY '5', an inner DECLARE N BYTE is DECLARE 5 BYTE, an error. The name used before the declaration is not declared there.
  • A dimension is a number, or a LITERALLY declared before it whose text is one (6.2.5), and not 0.
  • Empty parentheses are an error after a variable, x(), after a structure member, s.m(), after a subscript, a(1)(), and after a built-in, carry(); f() and CALL g() of a procedure are taken for f and g, with a warning, as programs written for uplm80 rely on them.
  • The address of a label, .label, may be given in a DATA or an INITIAL list, not in an expression (4.1.3); of the built-ins, only MEMORY has an address.
  • A DATA or INITIAL value, an AT address and a constant of a constant list .(...) are restricted expressions (4.1.3, 6.2.8, 6.2.9): numbers, added and subtracted, and a minus sign before a number; in a DATA or INITIAL list and a constant list a string alone; in a DATA or INITIAL list a location plus or minus numbers, .s.m(1) + 2, .memory among them, and in an AT one of a variable, not BASED, or of MEMORY; and in a constant list, or where the value fills a BYTE, what a byte holds. A built-in, a name not after a dot, parentheses, an operator but + and -, a string in a sum or in an AT, a location anywhere but first, one with two subscripts, with more than one in its parentheses or with a subscript on what is not an array, a constant list in a DATA list, and in an AT the location of a procedure or a label are errors at every -O level, as V3.1 makes them: data (shl(0f0h, 4)), data (x), .(x, 7), .(2 * 3), .(300), data (.a(1)(1)), data (.a(1, 2)), data (.x(1)), at (.p). A location in a DATA or INITIAL list or an AT address is the variable the name means in its block (9.1), declared before it or after, there or in a block around it, whatever the form of its declaration: .arr(2) of an ADDRESS array is ARR+4, and a procedure's .memory its own MEMORY where it declares one further on.
  • An INTERRUPT procedure is declared at the outer level of its module and has no parameters (8.1.6); so is a PUBLIC or EXTERNAL procedure or variable, not in a procedure or a DO block. INITIAL there initializes the variable once, when the program is loaded, with a warning.
  • A REENTRANT procedure is declared at the outer level of its module too, and has no procedure declared in it; a procedure calls itself, directly or from one declared in it, only if it is REENTRANT (8.1.7).
  • A direct call passes as many arguments as the procedure has parameters, and follows the procedure's declaration, but a call by a REENTRANT procedure of one that is REENTRANT too; .p(1) of a procedure is no address. CALL q(1, 2) of an ADDRESS q calls through it (8.2.1), as a CALL does through a structure's ADDRESS member or a BASED ADDRESS, and through the ADDRESS member of an array of structures named without a subscript, CALL sa.g, sa(0).g's; not through an array, an element, CALL sa(1).g, a structure, a BYTE or a label, nor of a built-in with a type, CALL STACKPTR.
  • An END that names a block names its own, the procedure or the label next to the DO; a DO CASE has a case, and a procedure a statement.
  • An array, MEMORY or a member array, is named without a subscript only after a dot or as the argument of LENGTH, LAST or SIZE (3.6.2), whose subscripts have nothing in parentheses in them. An array takes one subscript, and a scalar none: x(1), the element that far past x, and s2.m(1) of an array of structures, s2(0).m(1), are compiled with a warning, as before, since uplm80's own tests test them; s.k(1) of a scalar member is an error. Nothing follows a subscript but a member, a(1)(2) is an error, and INPUT and OUTPUT take one port.
  • SHL and SHR of a BYTE are a BYTE (11.1.4), and the bits shifted out of it are lost; SHL(DOUBLE(b), n) keeps them. uplm80 before 0.4.3 shifted a BYTE in 16 bits. Where a SHL of a BYTE can shift a set bit out and the bits above its low byte are used - stored to an ADDRESS, compared, used as a subscript, ... - the compiler warns at the SHL; and it warns at every flag reader - PLUS, MINUS, CARRY, ZERO, SIGN, PARITY, SCL, SCR, DEC - the flags of a SHL or SHR of a BYTE, or of an 8-bit operation on one, may reach, which are an 8-bit operation's since 0.4.3 (CHANGELOG, 0.4.3 and 0.4.4).
  • A flag reader reads at every -O level the flags it reads at -O0: the optimizer leaves as it is any operation whose flags a reader can read, where it folded x OR 0, x XOR 0 or SHL(3, 2) from -O1 on, and at -O3 an operation of a variable whose value it knew, a loop it unrolled, a test it decided or a store the next statement overwrites (0.4.4).

Conditional Compilation

PL/M-80 v4.0 added conditional compilation. The directives are control lines — a leading $ at the left margin (column 1), exactly like $INCLUDE and $TITLE — so the same source can target different configurations (e.g., CP/M 2.2 vs CP/M 3, single-user vs MP/M). No enabling directive is required.

Directives

Directive Description
$SET (NAME) Define a symbol
$RESET (NAME) Undefine a symbol
$IF NAME Compile following code if NAME is defined
$ELSEIF NAME Else-if branch
$ELSE Else branch
$ENDIF End conditional block
$COND / $NOCOND Listing controls only (accepted as no-ops)

A comment-wrapped form (/** $if NAME **/) is also accepted for CP/M-3-style sources that embed the same directives in comment syntax.

Example

$set (CPM3)
DECLARE
$if CPM3
    VERSION LITERALLY '30H',
$else
    VERSION LITERALLY '22H',
$endif
    MAXFILES BYTE;

Command Line

Symbols can also be defined from the command line:

uplm80 pip.plm -D CPM3 -D MPM -o pip.mac

Runtime Library

A module carries the runtime routines it uses at the end of its code (uplm80/runtime.py):

Routine Description
??mul16 16-bit multiply, HL = HL * DE
??div16, ??mod16 16-bit divide and remainder, as DRI's PL/M-80 computes them
??subde 16-bit subtract, HL = HL - DE
??jphl A CALL through an address (CALL q, Programming Manual 8.2.1): jp (hl)
??inp, ??outp INPUT and OUTPUT of a port that is not a constant

Calling Convention

A call passes its arguments the way Intel's PL/M-80 does, so code uplm80 compiles links with assembly written for PL/M-80 - DRI's X0100.ASM (mon1 equ 0005h), MP/M II's LDMONX.ASM and BRSPBI.ASM - and with what PL/M-80 compiled. (Up to 0.3.x it did not: see CHANGELOG.md, 0.4.0.)

Arguments Where they are at the call
0 nothing
1 a1 in BC (C for a BYTE parameter)
2 a1 in BC (C), a2 in DE (E)
n >= 3 a1 ... a(n-2) pushed left to right, one word each; a(n-1) in BC (C); an in DE (E)
  • At entry [SP] is the return address, [SP+2] is a(n-2), and so on to [SP+2(n-2)], which is a1.
  • A BYTE argument in a register is in C or E; B or D is undefined. A pushed BYTE is the low byte of its word; the high byte is undefined.
  • The callee takes the pushed words off the stack. The caller never adjusts SP after a call: when the callee returns, SP is what it was before the first push for the call.
  • A BYTE result is returned in A, an ADDRESS one in HL.
  • A call destroys A, the flags, BC, DE and HL. It keeps SP, and IX and IY: a REENTRANT procedure keeps its frame in IX across its calls.
  • The arguments are evaluated from left to right, each converted to its parameter's type. An EXTERNAL declaration only gives those types; PUBLIC, EXTERNAL, nested and REENTRANT procedures are all called the same way, and a direct call must pass as many arguments as the procedure has parameters.
  • A CALL through an address (8.2.1) places the arguments the same way, each widened to ADDRESS, and calls ??jphl with the address in HL. It may pass any number, to any procedure.
  • MON1(f, a) and MON2(f, a) with a constant f are compiled as the BDOS call itself, ld de,a / ld c,f / call 5 (call ??BDOS under -m mpm): the registers PL/M-80 sets for mon1 equ 5.
  • An INTERRUPT procedure has no parameters, and is at the outer level of its module (8.1.6).

One exception. A procedure with one parameter that nothing outside the compile can reach - not PUBLIC, EXTERNAL or REENTRANT, and its address never taken (.p anywhere, INITIAL and DATA included) - takes its argument in A (BYTE) or HL (ADDRESS) instead of C or BC, where its body usually wants it. No other module, no assembly and no CALL through an address can see the difference.

Writing an assembly routine that PL/M calls. Take the arguments as the table says; take the pushed words off the stack before you return; return a BYTE in A and an ADDRESS in HL; and keep SP, IX and IY. For

cap3: procedure (a, b, c) external; declare (a, c) address, b byte; end cap3;
        public  CAP3
CAP3:   ld      (VC),de         ; c, the last argument
        ld      a,c             ; b, in C
        ld      (VB),a
        pop     hl              ; the return address
        ex      (sp),hl         ; a, and the return address back on top
        ld      (VA),hl
        ret

With more pushed arguments, pop the return address, pop each pushed word (the last argument pushed comes first), and push the return address again. Assembly that calls a PL/M procedure does the same from the other side: push the first arguments, load the last two into BC and DE, call, and leave the stack alone afterwards.

Code built with 0.4.0 needs upeepz80 0.2.6 or later: 0.2.5 turned push ... / call p / ret into push ... / jp p, after which p takes its return address for its first argument. At -O1 and up the compiler refuses an upeepz80 whose version is below 0.2.6, unless it keeps that call (a development tree with the fix, numbered before 0.2.6 was released); -O0 does not use upeepz80.

Names in the Output

A module-level name is its own name in the assembly, and a procedure's names are @proc$name, but for the parameters and locals that share storage in ??AUTO (see Procedure locals). Where PL/M-80 would let two declarations meet in one assembler name - a label in each of two DO blocks, a procedure in each of two blocks, a LITERALLY in each of two procedures, a procedure or label in a DO block named like a local or a parameter of the procedure around it - one is renamed NAME?2 (no PL/M-80 identifier has a ?): a LITERALLY before a label, a label before a procedure, a procedure before a variable. A name the assembler reads as a register or an operator (A, HL, EQ, NUL, ...) is @NAME, and an offset from a name that ends in the letters of an operator is written first (2+TYPE, not TYPE+2); um80 0.3.51 reads the operator names and TYPE+2 as M80 does, and needs neither, but older um80 releases do. uplm80/names.py binds every name to the declaration PL/M-80 means and checks every GOTO against the Programming Manual (9.3): out of a procedure only to a label at the outer level of the main program module.

Runtime Modes

The first 100H bytes of a CP/M program's memory are reserved by the operating system (zero page, default FCB, default DMA buffer). All CP/M .COM programs load at address 100H, so a CP/M binary's contents start at offset 0 of the file — the linker takes care of relocating to 100H. PL/M source files should not declare 100H: themselves; doing so causes the assembler to emit a cseg org 100H, which the linker then honors by padding the binary with 256 zero bytes from 0–FFH. See the bare mode notes below for the one situation where a leading address constant is meaningful.

CP/M Mode (default: -m cpm)

The mode to use for new PL/M-80 programs. The compiler emits a small entry preamble that takes maximum stack space under BDOS and returns cleanly to CP/M:

  • The compiler emits the entry code at the start of the .com image — do not write 0100H: in your source. The linker (ul80) defaults to origin 100H, which is what CP/M wants.

  • Entry preamble (auto-generated):

    ld   hl,(6)       ; load BDOS base from address 6
    ld   sp,hl        ; stack grows down from just below BDOS
    call MAIN         ; run your main procedure
    jp   0            ; warm-boot return to CP/M when MAIN returns
    
  • Stack: maximum available — everything between program end and BDOS.

  • The BDOS interface procedures a program declares EXTERNAL - MON1, MON2, MON2A, MON3 - and BOOT are equates, as DRI's X0100.ASM defines them: a call of MON1 already has the function in C and the argument in DE, which is what the BDOS at 5 takes.

            public  MON1, MON2, MON2A, MON3, BOOT
    MON1    equ     5
    MON2    equ     5
    MON2A   equ     5
    MON3    equ     5
    BOOT    equ     0
    
  • System variables: BDISK, MAXB, FCB, BUFF, IOBYTE.

Bare Metal Mode (-m bare)

The mode required to rebuild original Digital Research utilities (PIP.PLM, ED.PLM, etc.) byte-compatibly. These programs follow the Intel PL/M-80 convention of jumping to start − 3 to skip over a local stack area:

  • Entry preamble (auto-generated):

    ld   sp,??STACK   ; a 64-byte stack in the data segment (see Memory Layout)
    jp   MAIN         ; jump (not call) into MAIN
    
  • The program controls its own exit — no automatic warm boot. Original DR utilities reboot or chain by writing to memory directly.

  • Custom entry points: because the entry preamble lives in the first few bytes of the image, original programs sometimes prepend a DECLARE … DATA(...) block to forge a different jump (see PIP.PLM, which fakes a JMP table at page 1). In bare mode the leading address constant (e.g. 0100H: or 0200H:) is meaningful — it sets the assembler org for the bare image.

  • Compatible with original Intel/DR PL/M-80 sources.

MP/M Mode (-m mpm)

For MP/M II page-relocatable modules. MP/M gives each process a memory segment and puts its page zero at the segment's base, so the page-zero addresses a program uses have to be relocated when it loads — and only a resolved symbol reference reaches a .PRL relocation bitmap.

  • Page-zero references are emitted as externals: ??BDOS (0005H), ??MAXB (0006H) and ??BOOT (0000H). Link with a small module that defines them at those addresses, using ul80 --prl (transient, linked at 100H) or ul80 --spr (system page, linked at 0); ul80 marks resolved page-zero symbol references for relocation.

  • The program's own externals - MON1, MON2, MON2A, MON3, FCB, TBUFF, BOOT and the rest - can come from DRI's PLM_WORK/X0100.ASM, linked unmodified, as DRI linked it (see Calling Convention); a banked resident process's from UTIL2/BRSPBI.ASM. ??BDOS, ??BOOT and ??MAXB remain the compiler's own.

  • Entry preamble (auto-generated), one three-byte instruction as DRI's PL/M-80 emitted — DRI's sources enter themselves by a jump to .start-3:

    ld   sp,??STACK   ; 512-byte stack carried in the image, before the variables
    
  • AT(.MEMORY) storage lies past the image; give the .PRL the memory it needs with ul80 --extra, as DRI did with GENMOD's third argument.

Memory Layout

A module is laid out the way Intel's PL/M-80 lays a program out, with the variables last:

  • Code segment (cseg): a BARE or MP/M module's own DATA first (DRI's programs begin with the jump byte data (0c3h) they enter themselves by), then the entry code, the procedures and the runtime routines, then every constant: string literals, .(...) lists, DATA declared in a procedure and, in CP/M mode, the module's own DATA, which there must not come before the entry code at 100H.
  • Data segment (dseg): ??AUTO, the procedures' shared locals; then the stack of BARE and MP/M modes (??STACK); then the variables, in the order the source declares them, a procedure's static locals among them.

ul80 places every module's data segment after all the code segments, including those of the runtime modules linked after it, so nothing follows a program's last variable and .MEMORY (the linker's __END__) is one past it. DRI's programs rely on that: MP/M II's SUB.PLM and MSPL.PLM use everything from their last variable up to MAXB as a buffer.

Procedure locals

PL/M-80 allocates a procedure's variables statically (Programming Manual, 8.1.7): a local keeps its value from one call to the next, and a program may count on it - a first-time flag, a running count. uplm80 saves memory by overlaying the storage of procedures that are never active at the same time, ??AUTO, but only for what no call can see the old value of:

  • Parameters, which every call assigns - the procedure's own entry stores the arguments it is passed (see Calling Convention) - unless a rule below makes one static.
  • A local that every call assigns before anything reads it. The compiler follows each procedure's statements, GOTOs included, and counts a call of a nested procedure that names the local as a read of it at the call; a use of a BASED variable reads its base. An array or structure is assigned once every element has been, through constant subscripts.

Every other local is static, @proc$name among the variables:

  • one that may be read before it is assigned;
  • one whose address is taken (.x, in a statement, an AT or an INITIAL) - a parameter too;
  • one named by a procedure nested in its own whose address is taken, or by anything that procedure calls: a call through the address can come when the procedure the local belongs to is not active;
  • one reached outside its bounds through a subscript: a scalar with any subscript but (0), an array (or an array member of a structure) with a constant subscript past its end, and a one-element array with any subscript but a constant 0. A subscript is a constant when it folds to one - a(1+1), a(-1) (which is a(255)), a(LAST(a)) - and it is decided the same way at every optimization level;
  • one declared with a static local in a factored declaration (6.2.4 makes those contiguous).

A local with INITIAL or PUBLIC is static in any case; a REENTRANT procedure's are on the stack.

DRI lays out what a procedure's text declares in the order it declares it: its parameters and locals, and among them the parameters and locals of the procedures nested in it and the variables of its DO blocks, where the text has them (MP/M II's SUBMIT has FILLRBUFF's ssbp at 0E7AH, the parameter of PUTRBUFF, declared next, at 0E7BH, and reading, which FILLRBUFF declares after PUTRBUFF, at 0E7CH). uplm80 keeps that order - the static locals among the variables in the order of the text, the others in the procedure's frame in ??AUTO, the parameters first, as the PROCEDURE statement lists them - wherever a program can tell:

  • every local declared after one whose address is taken, or which is reached outside its bounds, is static too: a pointer run on from it reaches them;
  • an array or structure subscripted by anything but a constant may run on into the locals declared after it, so from the first such array or structure on, a procedure's locals are all static or all in ??AUTO: if any of them is static (an INITIAL one included), all of them are. A read through such a subscript counts as a read of every local it can run on into;
  • a frame in ??AUTO holds only its procedure's own locals, so where a procedure with parameters or locals, or a DO block with variables, is declared after the local that either of those starts from, what comes after that local is static, the nested procedure's storage too, in the order of the text.

A subscript or pointer that runs backwards, before the variable, is not covered, nor is one that runs past a procedure's last local, or past a module-level variable into the procedures declared after it.

The analysis is in uplm80/local_storage.py.

Two procedures' frames in ??AUTO overlap only if the procedures are never active at the same time, which the call graph decides. It has the calls the program's text does not name as well: a CALL through an address (8.2.1) may call any procedure whose address is taken; an EXTERNAL procedure may call back any PUBLIC procedure and any whose address is taken (a call of MON1 or MON2 with a constant function is a call of the BDOS, which calls nothing back); and an INTERRUPT procedure, and everything it calls, may run while any procedure is active, so their frames overlap no other. A call's arguments need no edge: they are in registers or on the stack until the callee's entry stores them, when its caller is active too, so a procedure called while they are evaluated may overlay the callee's frame.

Testing against Intel's PL/M-80

scripts/intel_oracle.py is a differential test with Intel's own compiler as the oracle. It builds a program - a CP/M main program that prints through MON1/MON2 - with Intel's PL/M-80 V3.1 the way Digital Research's P.SUB built its CP/M programs:

PLM80 T.PLM DEBUG PAGEWIDTH(80)
LINK T.OBJ,X0100,PLM80.LIB TO T.MOD
LOCATE T.MOD CODE(0100H) STACKSIZE(1024)
OBJCPM T

and with uplm80 at -O0 to -O3 (linked with the same X0100 equates), runs every build under cpmemu with a time limit, and compares what each prints with what Intel's build prints. Each program is same, differs (both outputs are shown, and where they first part), intel-rejects, uplm80-rejects or timeout.

make -C tools/isis                                   # the ISIS-II emulator, once
python3.14 scripts/intel_oracle.py prog.plm          # programs of your own
python3.14 scripts/intel_oracle.py --random 300      # generated programs
python3.14 scripts/intel_oracle.py --corpus          # tests/ and sample_code/
python3.14 scripts/intel_oracle.py --random 50 --first 1000 --reduce --keep out/
python3.14 -m pytest tests/test_intel_oracle.py

tests/test_intel_oracle.py is part of the suite: python3.14 -m pytest runs it with the rest where Intel's binaries are found (building tools/isis first if it can), and skips it where they are not, as on CI, building nothing; the oracle's own pieces that need no tools - the source normalisation, the halt patch, the skip - are checked either way. It checks three generated programs and one of tests/, that a known difference is seen and a V3.1 rejection is, that V3.1's builds of the programs of the two V3.1 bugs 0.4.3's checks found print what they did (and, needing no tools, that the table below has them), each program of a release's tests whose output the release transcribed from Intel's build, and, for each program tests/test_names.py expects uplm80 to reject or warn of as V3.1 rejects it, that V3.1 does, with the errors uplm80's message names and no other.

Intel's binaries are not part of this repository. The oracle finds them through --tools DIR or $PLM80_TOOLS, or on DRI's MP/M II work disk at ~/src/mpm2/mpm2_external/mpm2src/PLM_WORK (PLM80, PLM80.OV0-OV4, PLM80.LIB, LINK, LINK.OVL, LOCATE, X0100, OBJCPM.COM), and when they are missing it says so and checks nothing, as the pytest skips. It runs them on tools/isis/isis, a small ISIS-II emulator (tools/isis/isis.cc) on cpmemu's qkz80 core, which make -C tools/isis finds the way romwbw_emu does (pkg-config, else a ../cpmemu checkout beside this one); without it, on romwbw_emu's tools/romwbw-plm80, which runs the same recipe under DRI's ISX, some ten times slower. OBJCPM is a CP/M program and runs on cpmemu.

Two things the recipe needs that DRI's own programs did for themselves:

  • LOCATE starts a program behind its constants (DATA, and strings in .(...)), not at 0100H, where CP/M enters it; DRI's programs begin with a DATA jump. A program that does not start at 0100H is located again at 0103H, where OBJCPM puts a jump to the start in front of it.
  • A PL/M-80 main program ends in EI; HLT, which cpmemu runs past. The HLT at the module's last statement (found through the LINES records DEBUG writes) becomes RST 0, a warm boot, as uplm80's CP/M mode ends; where a label on the module's END is one a procedure's GOTO reaches, fin: end t;, the EI; HLT follows the LXI SP V3.1 sets SP again with there.

A module with no statements of its own, entered through a DATA jump that Intel's layout puts at 0100H - DRI's CP/M 2.0 LOAD, STAT and SUBMIT - is built with uplm80's -m bare, which lays it out the same way (--mode chooses otherwise).

--normalize rewrites three spellings only uplm80 takes into their Intel equivalents before both compilers see the program: .'string' into .('string'), an untyped DATA into BYTE DATA, and a program with no name: DO; into a module. --reduce cuts a program that differs, or that uplm80 rejects, down to the statements, then the lines, the difference needs (Zeller's ddmin); the result needs tidying by hand before it is a test.

The generator, tests/plm_intel.py, writes programs in the dialect both compilers share, with no behaviour PL/M-80 leaves undefined but two, which V3.1 defines and uplm80 follows: division by zero (what Intel's divide routine returns) and the carry of PLUS, MINUS, SCL and SCR in the form where the statement itself sets it. It covers BYTE and ADDRESS arithmetic, relations and logic, the built-ins, arrays, structures, BASED variables, DATA/INITIAL, LITERALLY, strings, MOVE, LENGTH/LAST/SIZE, IF, DO CASE, DO WHILE, iterative DO (with limits and steps the body changes), nested procedures with 0 to 5 parameters, typed and untyped, and REENTRANT recursion. Evaluation order is kept out of what a program prints. Each printed line starts with the number of the statement that printed it, which the source carries as /* S1F */.

Known differences from Intel's PL/M-80 V3.1

The generator can leave out those with a name (--avoid NAME,...), and the pytest does. In the first campaign - 1,200 generated programs and the 67 programs of tests/ and sample_code/, at -O0 to -O3 - every difference was one of these, and none depended on the optimization level. Two more are gone: LENGTH, LAST and SIZE of a qualified reference, which uplm80 rejected, it takes since 0.4.2 (qualsize no longer leaves them out); and SHL and SHR of a BYTE, which uplm80 shifted in 16 bits, with an ADDRESS result, are a BYTE since 0.4.3, as the manual (11.1.4) and V3.1 make them (shl-byte is still taken, and leaves out nothing).

--avoid Program Intel V3.1 uplm80
shift9 b = 7; r = SHL(b, 9); (r BYTE) 0EH 0 V3.1 shifts a BYTE by the count mod 8 (0 counts as 8), folded or not; the manual: 0
wide-limit DO i = 250 TO 300; (i BYTE) 6 times never V3.1 compares the BYTE index with the 16-bit limit; the manual (5.1.4) converts the limit to the index's type
sub-zero w = (b - DOUBLE(0)) + 0F0H; (b = 0F0H) 00E0H 01E0H V3.1 drops - 0 and with it the ADDRESS type (also b - (c * 0)); the manual (4.2.1): ADDRESS
zero-dividend z = 0; w = 0 / z; 0 0FFFFH V3.1 folds 0 / x to 0, a dividend that overflows to 0 too, 256 * 256 / z, as it folds a product with 0, (b * 0) / z, and a dividend that folds to 0 by what folds to 0 too, (8 / 0FF00H) / (0F82AH <= 1); uplm80 divides, and a division by 0 gives what Intel's own divide routine gives (4.2.3: undefined)
neg-widened b = 0E7H; w = 0FFFEH; v = (b - w) + (-b); 0002 0102H V3.1 negates b in 16 bits when b - w has already widened it (the manual: -b is a BYTE, 19H)
w = 1; v = HIGH(0FH + w) >= (ROR(SIZE(aw), 2) AND w); (aw(8) ADDRESS) 0 0FFH V3.1 makes 10H from the 0FH in C with MOV A,C and INX SP (its listing: INX PSW) where it means INR A: the value is one short and SP one long. w1, b2 = b2; (b1-b4 BYTE, w1-w4 ADDRESS), as a program's first statement or later, is coded INX H; INX SP; MOV M,A, and a later CALL overwrites b1 (the random campaign's seed 233). The decrement form is the same: MOV A,L; DCX SP (DCX PSW) where it means DCR A, in DO CASE ((-(LAST(sa.z))) / ROL(0FFFFH, 9)) MOD 5; (seed 20226), and INX SP again in DO CASE (ROR((07H AND 08001H), 1)) AND 1; (0.4.3's seed 50265)
b = 0ECH; w = (ew := b) + b; (ew, w ADDRESS) 01D8H 00D8H V3.1 widens b to store it in ew and adds that 16-bit copy to itself, SHLD EW; MOV D,H; MOV E,L; DAD D (0.4.3's campaign, seed 50252); the manual (4.6.3): the embedded assignment is its right half, a BYTE, and BYTE + BYTE is a BYTE
w2 = 0FFFEH; w2, w3 = (ew := w2); (w2, w3, ew ADDRESS) w3 0000 w3 0FFFEH Where a multiple assignment's value is an embedded assignment of one of its targets but the last, V3.1 takes that target's value for an address, LHLD W2; SHLD EW; MOV D,H; MOV E,L; XCHG and 52 INX H: it copies the word there, at 0032H, onto itself and into each target after w2, and does not store w2. With a BYTE, w2, w3 = (eb := w2);, it stores that address, 0032H, at itself and in w3. How far from the value it goes changes with the program: 54 bytes on in another, 9 back in 0.4.3's release check's seed 80353, which stored 0FFF5H. w3, w2 = (ew := w2); is right; the manual: the embedded assignment is w2 (4.6.3), which each target gets
CALL MOVE(0, .s, .d); moves 65536 bytes moves none Intel's MOVE counts down before it tests
b = SHL(k, 1); c = d OR 0; e = CARRY; (k = 0FFH, d = 1) 0FFH 0 V3.1 compiles no instruction for x OR 0, x XOR 0, x + 0, x - 0, x AND 0FFH and an operation of constants, DEC(34H + 21H), and a flag read after one reads the flags of what came before; uplm80 computes each, as its -O0 build always did and every level does since 0.4.4. And the control code of a loop leaves other flags: DO i = 1 TO 2; c = 5; END; e = CARRY; is 0FFH with V3.1 and 0 with uplm80, DO WHILE z; ...; END; e = ZERO; of z = 0 the other way round. Other operations leave other flags with V3.1 than with uplm80 too, as in 0.4.2 and 0.4.3: x + 1 and x - 1 of a BYTE are INR and DCR, which set no carry, c = d + 1; e = CARRY; after the SHL 0FFH with V3.1 and 0 with uplm80; a SHR of a BYTE, and a SHL V3.1 makes a rotation, is an ANI and then RARs, so that SIGN, ZERO and PARITY after it are of the masked value before the shift, b = SHR(k, 3); e = SIGN; 0FFH with V3.1 and 0 with uplm80; and after some comparisons and IF tests, a MOVE (V3.1's leaves ZERO set), TIME, a DO CASE's dispatch, subscript arithmetic, x * 0, x / 1 and NOT of a comparison. Of 275 cases of 0.4.4's checks where a reader reads the flags of a shift, 256 print what V3.1's build prints and 19 are of these kinds. The manual: the flags are not to be relied on (12.1)
rw = ((08B1FH - b3) XOR (-((b4 >= ms(3)) AND 0))) + 0; (b3 = 20H, b4 = 0, ms DATA('x=1; y=2$')) 0 8AFFH V3.1's count of what it has on the stack goes below zero (its listing counts 0, 255, 254, ...) and it pops what it never pushed, a run of POP PSW (seed 20090); 8AFFH is the manual's value

V3.1 also rejects what only uplm80 takes: .'string' (ERROR 101), an untyped DATA (61), a program that is not a module (89), a declaration after a statement or among a DO CASE's cases (26; uplm80 refuses a DO CASE of declarations alone as a DO CASE with no case, 201), NOT NOT x (102), and a CALL of a typed procedure the program declares (129); and what the CHANGELOG lists under Known issues: more INITIAL or DATA values than a scalar or an array holds, or an empty string in a DATA list (209), an AT that names a variable AT something further down (213), a typed procedure with no RETURN (156), a label as a value or an assignment's target, w = lb (132), a number above 0FFFFH (94), a port of INPUT or OUTPUT that is not a constant, INPUT(b) (107), a REENTRANT EXTERNAL procedure (41, 174), and .memory.x, which uplm80 compiles to a MEMORY symbol that um80 finds undefined (32, 110). uplm80 compiles, with a warning that names V3.1's error, f() and CALL g() of a procedure (102, 153) and INITIAL in a procedure or a DO block (73), on which programs written for it rely, and a subscript on a scalar, x(1) (127), but for the location of one in a restricted expression, and a member of an array of structures without its subscript, s2.m(1) (133), which its own tests test. It rejects, as V3.1 does, a name declared nowhere, empty parentheses after a variable or a built-in, .label in an expression (158), an INTERRUPT procedure below module level, a parameter no DECLARE declares, and a LITERALLY used before its declaration (since 0.4.1); a dimension of 0, the address of a built-in but MEMORY, and a PUBLIC or EXTERNAL procedure or variable below module level (since 0.4.2); and an END that names another block (20), a DO CASE with no case (201), .p(1) of a procedure (104), anything in parentheses in a subscript of the argument of SIZE, LENGTH or LAST, size(ab(f(1))) (32), a procedure with no statements (174), two subscripts on a scalar (127, 114), an array or a member array without a subscript (133, 134), and a call of a procedure declared further on (169) (since 0.4.3); and more than one subscript (114), a subscript on a scalar member (127) or after a subscript (32), a second port of INPUT or OUTPUT (108), a CALL through what is not an ADDRESS scalar (118) or of a built-in with a type, CALL STACKPTR (129), a built-in with too few or too many arguments (154, 153, 124, 126) and INPUT or OUTPUT without a port (109), MEMORY without a subscript (133), a non-REENTRANT procedure's call of itself (170), a procedure nested in a REENTRANT one or a REENTRANT one nested in another (88, 39, and 174 of one with no statements), an END that names the first of two labels on a DO, a: c: do; ... end a; (20), and an assignment to a built-in or a procedure, INPUT(1) = b (128, 131 of one without a type, and 154, 153, 109, 108, 124 or 126 where its arguments are not as many as it takes, TIME = 1) (since 0.4.4); and in a DATA or INITIAL list, an AT address or a constant list what a restricted expression does not take, with the errors V3.1 gives for the list (since 0.4.4): a built-in or a name, data (shl(0f0h, 4)), data (x), .(x, 7) (151); parentheses, .((1 + 2), 7), an operator but + and -, .(2 * 3), data (6 / 3), NOT, .(not 0f0h), a string in a sum, data ('A' + 1), and a location anywhere but first, at (3 + .buf(1)) (152, 146, 151); a location with two subscripts, data (.a(1)(1)) (152, 146), with more than one in its parentheses, data (.a(1, 2)) (150), or with a subscript on what is not an array, data (.x(1)), data (.p(1)) (149); the location in an AT of a procedure or a label, at (.p) (211), or of a BASED variable (212); a constant list in a DATA list, data (.(5)) (147); and in a constant list, or where the value fills a BYTE, a location or a number a byte does not hold, .(300, 7), byte data (.w) (210); and a base V3.1 does not take (since 0.4.4): BASED, or a member of what is, a based s.p of a BASED s (50, 52), a member of an array of structures (52), a BYTE, an array, a structure, a built-in, a procedure or a label (50), a member its structure does not have (55) or a name declared only further down (54); and a LABEL that labels no statement (172, and 105 where the program names it, and 118 where a CALL calls it). It refuses the location of a built-in but MEMORY in a DATA or INITIAL list, data (.stackptr), which V3.1 takes for an address of its own: uplm80 has none to give it; and the location of a REENTRANT procedure's local in a DATA or INITIAL list or an AT, and of a BASED variable, a factored BASED declaration's too, in a DATA or INITIAL list, which V3.1 gives an address (INITIAL in a procedure it rejects, 73): uplm80 has the local on the stack, and no address of a BASED variable to give. And V3.1 computes a restricted expression as a constant, a number below 256 a BYTE and BYTE arithmetic in eight bits, address data (200 + 100) 002CH and -1 00FFH, where uplm80 computes in sixteen bits, 012CH and 0FFFFH, and a string that fills an ADDRESS there as a constant, address data ('AB') 4142H, where uplm80 lays out 'A' and 'B' in order, 4241H (CHANGELOG, Known issues).

A store through a pointer or an overrun in or from ??AUTO reaches what uplm80's layout puts there, not what DRI's does (CHANGELOG, Known issues), which the generator never writes. Since 0.4.2 uplm80 compiles a label on an END statement, out: end p; (A.4.4.1), as V3.1 does, and a counted loop over the last module-level variable sees a store through MEMORY that reaches it.

Project Structure

uplm80/
├── compiler.py      # Main compiler driver / CLI
├── frontend.py      # plox-driven lexer + LR parse → AST
├── preprocess.py    # PL/M preprocessor ($INCLUDE, $if, LITERALLY, ...)
├── ast_nodes.py     # AST definitions
├── ast_optimizer.py # AST-level optimizations
├── codegen.py       # Z80 code generator
├── local_storage.py # Which procedure locals may share ??AUTO
├── names.py         # Which declaration each name means; GOTO rules; assembler names
├── runtime.py       # Runtime helpers
├── symbols.py       # Symbol table
├── errors.py        # Diagnostic exception types
└── data/            # Pre-built plox grammar bundle (plm_full.json)

Peephole optimization is provided by the external upeepz80 package; the front-end is generated from plox grammars (plm_pre + plm_full) and loaded at import time from the JSON bundle in data/.

License

This project is licensed under the GNU General Public License v3.0 or later - see the LICENSE file for details.

Contributing

Contributions are welcome! Please feel free to submit issues and pull requests.

  • 80un - Unpacker for the CP/M archive and compression formats LBR, ARC, squeeze, crunch, and CrLZH.
  • cpmdroid - Z80/CP/M emulator for Android phones and tablets. It emulates the RomWBW HBIOS interface and a VT100 terminal.
  • cpmemu - Z80/CP/M emulator for Linux and Windows, with Z80 and 8080 CPU cores. It translates the BDOS and BIOS calls of CP/M 2.2 programs to the host file system.
  • ioscpm - Z80/CP/M emulator for iOS and macOS. It emulates the RomWBW HBIOS interface and runs CP/M 2.2 and CP/M 3.
  • learn-ada-z80 - Collection of more than 90 Ada example programs for uada80, the Ada compiler for the Z80 processor and CP/M.
  • mbasic - Python interpreter for MBASIC 5.21, the Microsoft BASIC-80 for CP/M. Two compiler backends compile the programs to CP/M .COM files or to JavaScript.
  • mbasic2025 - Reconstruction of the lost source code of MBASIC 5.21, the Microsoft BASIC-80 for CP/M. The MACRO-80 source code assembles to a binary that matches mbasic.com byte for byte.
  • mbasicc - C++17 interpreter for MBASIC 5.21, the Microsoft BASIC-80 for CP/M. It runs on Linux and macOS.
  • mbasicc_web - Web browser interpreter for MBASIC 5.21, the Microsoft BASIC-80 for CP/M. Emscripten compiles the mbasicc interpreter to WebAssembly.
  • mpm2 - Z80 emulator for MP/M II, the multi-user CP/M operating system. Users connect over SSH, and SFTP clients transfer files.
  • romwbw_emu - Hardware-level Z80/CP/M emulator for Linux and macOS. It emulates the RomWBW HBIOS interface and switches banks in 512 KB of ROM and 512 KB of RAM.
  • scelbal - Floating-point BASIC interpreter for the 8080 processor and CP/M. A translator converts the original 8008 source code to 8080 source code.
  • uada80 - Ada compiler for the Z80 processor and CP/M 2.2. It compiles a subset of Ada 2012 to CP/M .COM files.
  • uc80 - C compiler for the Z80 processor and CP/M. It optimizes for small code size.
  • ucow - Cowgol compiler for the Z80 processor and CP/M. It runs on Linux in Python.
  • um80_and_friends - Linux toolchain that is compatible with Microsoft MACRO-80. It has an assembler, a linker, a librarian, and a disassembler.
  • upeepz80 - Peephole optimizer for Z80 compilers that write lowercase Z80 assembly language. It shortens jumps to jr, builds djnz loops, and removes dead stores.
  • uplox - LR(1) and GLR parser generator. It writes a lexer DFA, an LR parser, and a typed automatic AST, and uplm80 uses it to parse PL/M-80.
  • z80cpmw - Z80/CP/M emulator for Windows. It emulates the RomWBW HBIOS interface and boots CP/M from disk images.

Metadata

Release files for uplm80 0.4.4

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for uplm80 0.4.4
File Size Uploaded
uplm80-0.4.4.tar.gz 456.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for uplm80 0.4.4
File Interpreter ABI Platform
uplm80-0.4.4-py3-none-any.whl Python 3 none any Details

Total release size: 745.6 kB

Release files / uplm80-0.4.4.tar.gz

Download URL uplm80-0.4.4.tar.gz
Size 456.2 kB
Tags Source
SHA-256 checksum
How to use checksums
b438515da790d764f37704d6d4f46771a4fe3d8db8c647346d56d21913f63315
BLAKE2b-256 checksum
How to use checksums
b2efe352bc3dac67d42c245fa09c865973cc4ef17ee910c1a0793c89d3cc8616
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 27, 2026.

Transparency log

Release files / uplm80-0.4.4-py3-none-any.whl

Download URL uplm80-0.4.4-py3-none-any.whl
Size 289.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
3f58dbf619ead99b2cf4eda74eaa976729ba9824293c49e656d9bec92781d524
BLAKE2b-256 checksum
How to use checksums
e4c01ebaf515b66b5ae2dccb0e49f5dd7a13957b2f7d1db84f267372a893953a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 27, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.4.4 This release

2 release files

0.4.3

2 release files

0.4.2

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.7

2 release files

0.3.6

2 release files

0.3.4

2 release files

0.3.3

2 release files

0.3.2

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.32

2 release files

0.2.28

2 release files

0.2.22

2 release files

0.2.21

2 release files

0.2.20

2 release files

0.2.19

2 release files

0.2.18

2 release files

0.2.17

2 release files

0.2.16

2 release files

0.2.15

2 release files

0.2.14

2 release files

0.2.13

2 release files

0.2.12

2 release files

0.2.11

2 release files

0.2.9

2 release files

0.2.8

2 release files

0.2.7

2 release files

0.2.6

2 release files

0.2.5

2 release files

0.2.4

2 release files

0.2.3

2 release files

0.2.2

2 release files

0.2.1

2 release files

0.2.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page