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 because step is 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, FDVL were said to trap on a KL10 -- apsim's comment, inherited -- and a pdp10-kl build 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.py drives apsim's single-step mode and pymdl's Machine on the same generated operands; tools/kl10_difftest.py drives a pdp10-kl build through its console for values and KLH10 for the flags word, which found the sticky carries, the nine-bit shift count, the PUSH order and the MOVN family'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 to FINIS at 703036 and MPOPJ at 760454 and MCALLs PSTRNG at 757151, where release 55's table places the same entries at 703120, 763432 and 762000. register_era_symbols with the 54 addresses makes that RSUBR run: the MCALL reaches pymdl's PRINTSTRING and the MPOPJ returns (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 ,STRING over 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 as LIST to 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 mudsys makes 23 distinct .CALLs (through a DOTCAL macro, 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's TTYGET writes zeros to every output word and pymdl returns the stored terminal status, including "this is a console"; apsim's TTYSET advances the PC and discards the request, pymdl stores it, and MDL's readch reads then sets, so its request survives here and is dropped there. Those zeros from TTYGET were 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.