The pymdl book
36. The PDP-10 inside
MDL's historical compiled code is PDP-10 machine code (chapter 33).
pymdl can execute it in an emulator within the Python process. Applying an
RSUBR loads its code vector and transfers control to the emulator. When
the compiled code calls an interpreter service, pymdl handles that call
in Python. Registered Python peers can replace selected compiled routines.
This chapter explains the processor model, the representation of MDL objects at the machine boundary, the emulated ITS services, and the effect of disabling the emulator. The emulator supports compatibility with archived binaries and newly compiled PDP-10 code. For host performance, the native compiler described in chapter 34 is a separate path.
36.1 The machine
The model is a PDP-10 as MDL used it: 36-bit words, sixteen accumulators
(addresses 0 to 15 read the accumulators, as on the real machine), 18-bit
effective addresses with indexing and indirection, no paging and no priority
interrupt system. The instruction families MDL code uses are implemented
whole: the full-word and half-word moves, the sixty-four booleans, the
sixty-four tests, fixed and floating arithmetic, shifts, skips and jumps, the
stack and byte instructions. Dispatch is a table of thirty-five family
handlers claiming 309 of the 512 opcodes, disjoint, each a method you can
find by its octal opcode the way a PDP-10 reference is indexed. Anything
else raises UnimplementedInstruction with the opcode and the PC, which is
the signal to grow the set, and has been: the UNWIND protocol's
IUNWIN, POPUNW and UNWIN2 arrived when the era compiler's output for
UNWIND could not run (34.5.4).
Found along the way. Two claims about the dispatch's speed were made and both were wrong. The table replaced a 317-line chain of 83 comparisons; the chain was said to cost "about 40 tests per instruction" (it was frequency-ordered, so it never did) and the table was then reported 25% slower (from two samples). Measured properly, interleaved A/B over the era compiles, sixteen runs each: median +0.2%, permutation p = 0.43, nothing. Across those compiles the emulator executes 1,860,240 instructions and dispatch plus every handler is 5.8% of the total, so no dispatch change could move the total more than about 1.5% either way. The lesson kept in the docstring: measure before believing either claim.
Measured, 2026-08-29 (
tools/emubench.py). A callback-free instruction loop runs at 0.62 microseconds per emulated instruction, about 1.6 million a second. A raw era-compiler build executes 972,593 instructions, so the whole emulator is about 0.6 seconds of a ten-second build -- 6%, not the half that a profile suggests. The profile misleads becausestepis re-entrant: emulated code calls back into MDL, which re-enters the machine, and cumulative time charges every callback's interpreter work to the emulator. An infinitely fast emulator would win 6%, which is why the emulator is not where pymdl optimises.
Which PDP-10. The default is a KL10, the machine the era compiler's
output was built for, and the default is load-bearing: the models differ in
POP into its own pointer, in the long-form floating arithmetic (software
format on the KA10, register-level on the KL10), in an alignment guard at
long range that splits KS10 from KL10. A KA10 model exists (cpu_ka), with
its own flags differential. Floating point began as a port of apsim's
implementation and is now, for the whole 140-177 set, a register-level port
of the KL10 algorithms out of SIMH's kx10_cpu.c, validated at zero
divergence against a local pdp10-kl build over every form.
Found along the way. The oracles for floating point changed twice, because "checked against apsim" and "checked against SIMH" mean different things per opcode and per processor. The long forms
FADL,FSBL,FMPL,FDVLwere said to trap on a KL10 -- apsim's comment, inherited -- and apdp10-klbuild executes them; they are KS10 MUUOs. Floating divide followed apsim's exact guard-bit arithmetic until reading the KL10 algorithm showed the machine develops a 29-bit quotient and rounds it through a specific shift sequence, so the hardware is inexact and exact division was unfaithful: pymdl now sides with SIMH against apsim in the two cases in five where they differ by one unit in the last place. The rate was first recorded as "one in a hundred" from a sweep that drew each divide two or three times per run; a rarely drawn instruction reads as a rarely failing one.tools/apsim_difftest.pydrives apsim's single-step mode and pymdl'sMachineon the same generated operands;tools/kl10_difftest.pydrives apdp10-klbuild through its console for values and KLH10 for the flags word, which found the sticky carries, the nine-bit shift count, thePUSHorder and theMOVNfamily's missing flags. The first version of the apsim harness compared the program counter alone -- a key-normalising bug -- and reported "354 opcodes swept, 0 differing"; it now refuses to run unless at least seventeen fields compare.
36.2 The address space and the interpreter
The address space is virtual over pymdl. User space -- code, the stacks,
argument blocks, rendered data -- lies below the interpreter. The heap grows
down from the top of that region and the code region grows up, so the two
share one span and collide only when the machine is genuinely full (a fixed
split could not serve both the raw build, which loads some 83,000 words of
FBIN code, and a data-heavy compile). Addresses in interpreter space are
the release's own: the symbol addresses of MUDSAV;SQUOZE TAB55, the same
table SQUOTA answers from and the assembler resolves against (35, 7.1.5).
Jumping or PUSHJing to one of those addresses traps into a Python
service operating on pymdl's real objects. JRST 703120 is FINIS
because the table says so, and FINIS pops the frame and hands the result
in A and B back to the interpreter; MPOPJ undoes the SUBM M,(P)
return convention of a GLUEable internal entry; the frame services
(CFRAME, CARGS, CFUNCT) answer what the era's interpreter answered
about the running frame. There are some ninety of them, methods of one
_Services object (they were eighty-two closures nested in a 1,683-line
function once, invisible to a search for def).
<SQUOTA <SQUOZE "FINIS">> ⇒ 230992
<SETG ADD1 <RSUBR [#CODE ![17199005697 24847056897 17859346433 23085680208!] ADD1 #DECL ("VALUE" FIX FIX)]>>
<TYPE ,ADD1> ⇒ RSUBR
<ADD1 41> ⇒ 42
Those four words are the hand-built RSUBR of tests/test_pdp10.py -- MOVE B,1(AB), ADDI B,1, MOVSI A,1 (the type code of FIX, 35's 7.1.7),
JRST 703120 -- and 230992 is 703120 octal. A CODE prints and reads in
decimal (33.11).
Calls out of compiled code. A compiled call to an interpreter SUBR is
an MCALL UUO naming the routine by its assembly label, and the label is not
always the MDL name: the interpreter's MIDAS sources declare MFUNCTION MRETUR,SUBR,[RETURN] and MFUNCTION GASCII,SUBR,ASCII, so the symbol table
knows MRETUR and GASCII where MDL knows RETURN and ASCII, and 108 of
the built-ins are like that. The bridge maps the labels the era compiler
emits to pymdl's built-ins (UNASSI to UNASSIGN, EVECTO to VECTOR,
FREAD to READ and so on) and the call runs the Python SUBR with the
arguments unmarshalled from the stack. Code built against a different
interpreter image calls entries at that image's absolute addresses; a host
may register them.
Measured, 2026-08-29. July 1977's Zork
TELL, assembled against MDL 54, jumps toFINISat 703036 andMPOPJat 760454 andMCALLsPSTRNGat 757151, where release 55's table places the same entries at 703120, 763432 and 762000.register_era_symbolswith the 54 addresses makes thatRSUBRrun: theMCALLreaches pymdl'sPRINTSTRINGand theMPOPJreturns (tests/test_pdp10.py).
36.3 The calling convention
The convention is the assembler's own output (35's 7.1.9), and the emulator keeps it exactly:
| register | holds |
|---|---|
AB |
the argument block: two words per argument, a type word [type,,extra] and a value word |
TP |
the value stack |
TB |
the frame pointer (one word above the frame's last word) |
M |
the code base; label references are M-indexed, which is what lets code move |
R |
the reference vector (33.2) |
P |
the machine stack |
A, B |
the result's type word and value word, through FINIS |
FRM |
the compiled FRAME register the FMPOPJ family unwinds |
The frame layout on TB is the interpreter's -- seven words, FSAV,
OTBSAV, ABSAV, SPSAV, PSAV, TPSAV, PCSAV at offsets -7 to -1 --
and OTBSAV = -6 is confirmed for this release by the symbol table itself,
which exports it, because compiled code bakes it in (newrep's MOVE TB,OTBSAV(TB) resolves through SQUOTA). One value is marked as not
measured: TCBLK, the type code BFRAME writes into a funny frame's FSAV,
appears in no source or export at hand and nothing in a compiled run reads it
back, so the emulator holds a placeholder for the frame's shape and says so.
Type words carry the codes of 35's 7.1.7, pinned to MDL 55's answers for
every built-in (tests/test_type_codes_era.py). The one that went wrong is
instructive: UNBOUND was missing from the code table, so it drew a fresh
code past NUMPRI, and every "OPTIONAL" argument without a default --
whose unsupplied slot is the UNBOUND type word -- compiled to PUSH TP,7(R) where MDL 55 emits PUSH TP,-77771: same word count, one word
different, correct answer, invisible to every size check until the oracle
(34.8) compared words.
Re-entrancy. A compiled routine calls the interpreter, which may apply
another compiled routine, which pushes its frame on the same stacks, as an
era MCALL did. Each interpreter has one persistent Machine, built
lazily, and execute_rsubr re-enters it per call with the register file, the
running RSUBR's identity and the bottom-frame marker saved and restored
around each entry. Under pymdl's resumable-frame machinery (chapter 24) the
machine's state is opaque, and a SAVE beneath it falls back honestly.
36.4 The boundary
MDL objects have to become words for the code to touch them, and words have
to become objects again. The persistent machine changed how: the per-call
model before it re-rendered every reachable object into a fresh heap on each
call, so identity, mutation and cross-call state survived only as well as the
reflection covered them, and nine engine bugs fell in that seam. Now an
object marshals once and its machine words are its long-term
representation, with a handle map from word address back to object. On each
outermost entry the words of registered vectors and uvectors and the
GLOTOP cells (the global-value slots compiled SETGs write) are refreshed
from interpreter state, skipping any structure whose store generation has
not moved; on each outermost exit the run's journal of core writes is walked
and only the owners actually touched are reflected back -- a compiled SETG
through a GLOTOP cell, a vector element, a uvector element resolved through
the handle map and the element type rather than blindly as a FIX.
PYMDL_FULL_BOUNDARY restores the full sweeps for differential debugging.
One limit is documented rather than hidden. LIST cells are rendered
read-only, once: an interpreter-side PUT into a list is invisible to
compiled code still running in the same machine entry, which the constant
folder's compiled PASS:1 and PASS:2 hit. Vectors and uvectors are
refreshed because their element words are addressable; coherent lists would
need a dirty set with mutation hooks on the objects, which is the standing
next step and has not been needed.
Found along the way. The generic compiled loop over a structure of unknown type (
MAPF ,STRINGover anything, oracle case MQ6) writes its saved type word straight back over the pair's type word after each element and re-enters the type dispatch on it. The boundary once stored the bare count in that word; a zero left half reads asLISTto the dispatch, and the walk fell apart after one character. The whole type word, count in the right half, is what the era stored, and what the emulator stores now.
36.5 The operating system underneath
A machine is not a system. MDL's compiled code, and MDL itself, reach ITS
through the .CALL UUO (opcode 043), and the emulator dispatches that one
trap to a separate module, pdp10_its.py, which is the whole boundary
between the processor and the system it runs under. The layer serves the
calls the era code pymdl runs actually makes -- OPEN, CLOSE, IOT,
SIOT, ACCESS, FILLEN, RFNAME, RQDATE, RFDATE, CORBLK, the
terminal calls TTYGET and TTYSET, and their kin -- against pymdl's
channels and file root (chapter 2), and for a call it does not implement it
stops with the call named: an invented answer would be worse than a stop
that says what is missing. A .CALL argument word is an effective-address
specification unless its CIMM flag says the right half is the value, and
both shapes are live in the corpus (tests/test_its_call.py). The Tenex
door is present too: JSYS (opcode 104) is to a TOPS-20 program what
.CALL is to an ITS one, and it has the same shape, a hook a host installs
and a named fault when none has. pymdl is the superset of both systems
(chapter 3); the engine supplies the instruction and a Tenex-side host would
supply the monitor.
Found along the way. MDL's own
mudsysmakes 23 distinct.CALLs (through aDOTCALmacro, so a search for the literal finds almost nothing). Surveyed against apsim, the other ITS simulation these projects run era code on: thirteen both implement, six apsim alone, four neither. Where both implement one and differ, the difference matters more than the coverage. apsim'sTTYGETwrites zeros to every output word and pymdl returns the stored terminal status, including "this is a console"; apsim'sTTYSETadvances the PC and discards the request, pymdl stores it, and MDL'sreadchreads then sets, so its request survives here and is dropped there. Those zeros fromTTYGETwere an invented answer that happened to describe a specific terminal, and they looked authoritative enough to carry three sessions of reasoning about a case-folding difference that turned out to live in apsim's own terminal input. A stub is a fact about the simulator, not the machine.
36.6 The files
Every era binary pymdl reads -- NBIN, FBIN, the library databases,
SAV FILE -- came off DUMP tapes through Alan Bawden's itstar, which
stores each 36-bit word in 8-bit bytes: five 7-bit ASCII bytes when the word
is text, so PDP-10 ASCII reads directly, and otherwise a five-byte escape
whose lead byte carries the top four bits. its36.py decodes and encodes
that format, transcribed from the note in itstar's pack.c, and the
decoder is held against the reference implementation on random encodings
(tests/test_its36_decode.py). It is the reader under 33.11 and 33.12 and
the writer under FILE-ASSEMBLE's NBIN.
36.7 Switching it off
PYMDL_NO_PDP10=1 disables the emulator. Every door into it -- applying an
RSUBR whose code slot is machine CODE, executing a pure-code entry,
building a machine, an ITS call -- refuses with NoEmulator and names the
door, so what then fails is exactly what needs the machine and nothing else.
The assembler still assembles (chapter 35); the library still loads, because
the library's compiled RSUBRs have Python peers at the apply boundary
(33.14); MIT's compiler still builds, from source, and still compiles all
fourteen of its gate samples (34.7.3), because every piece of machine code
in its tree has a peer held against the era words. Only the running of a
compiled RSUBR with no peer is lost.
tests/pdp10census.py is a pytest plugin that records, per test, which of
the doors it went through, so "does this test need the machine" is a
measurement. Appendix D has the knobs: PYMDL_NO_PDP10,
PYMDL_FULL_BOUNDARY, PYMDL_PDP10_DATA for the archive directories a
program's FLOAD of an era helper binary may need to search.
36.8 The debugger
Above the machine sits a small DDT, ddt.py, because on ITS MDL ran as an
inferior of DDT and the RDB package of chapter 35 (7.2) talks to its
superior. It owns the symbol table the RSUBRs are frozen into and the
breakpoint set the machine consults at each step, seeded from the release's
own full symbol table (mdl/mudsav/ddt.syms55), and it serves the stop: an
examine, deposit, step and proceed loop over the live core, with addresses
rendered symbolically. A deposit at a stop patches the running code, which
is the reason RDB existed.