The p-code toolchain compiles Pascal source to executable p-code binaries
that run on the pv24a virtual machine (hosted on the COR24 emulator).
The toolchain is designed around text-based .spc (symbolic p-code)
as the interchange format. All linking happens at the .spc text level —
there is no binary linker. A single merged .spc file is assembled into
a .p24 binary, which is the input to the loader.
Pascal source (.pas)
|
v
p24p (Pascal compiler, C, runs on COR24 via tc24r)
| emits .spc files (one per module/unit)
v
.spc files (app) + .spc files (user libs) + .spc files (runtime/pr24p)
| | |
+----------------------+--------------------------+
|
v
pl24r (text-level linker, Rust) <-- exists, awaiting .<meta> support
| concatenates + resolves cross-module references
| all symbol resolution happens here, at the text level
v
one merged .spc file (all symbols resolved)
|
v
pa24r (assembler, Rust) <-- to be created
| two-pass assembly, resolves all offsets
| library crate with thin CLI wrapper
v
.p24 (p-code binary, self-contained)
|
v
loader → pvm (VM) → COR24 emulator
All linking is text-level concatenation and symbol resolution in .spc format. By the time pa24r sees the merged .spc, every symbol reference is resolvable — forward references are handled by two-pass assembly. The .p24 output is a fully-resolved, self-contained binary. The loader's job is simply to place bytes in memory and initialize VM state.
| Project | Language | Location | Status |
|---|---|---|---|
| p24p | C (compiled by tc24r) | ~/github/softwarewrighter/p24p |
Pascal compiler emitting .spc |
| pr24p | Pascal + hand-written .spc | ~/github/softwarewrighter/pr24p |
Runtime library source files |
| pl24r | Rust | ~/github/softwarewrighter/pl24r |
Text-level linker, awaiting . producers/consumers |
| pv24a | COR24 assembly | ~/github/sw-vibe-coding/pv24a |
VM + assembler (pvm.s, pasm.s, pvmasm.s) |
| cor24-rs | Rust | ~/github/sw-embed/cor24-rs |
COR24 assembler + emulator |
| tc24r | Rust | ~/github/sw-vibe-coding/tc24r |
C compiler for COR24 |
| Project | Language | Purpose |
|---|---|---|
| pa24r | Rust | P-code assembler: .spc -> .p24 binary (library + CLI) |
The Pascal runtime (pr24p) is a collection of source files:
- Pascal modules compiled by p24p to .spc (e.g., string handling, I/O formatting)
- Hand-coded .spc files for low-level primitives that can't be
expressed in Pascal (e.g.,
writelnin early bootstrap, syscall wrappers)
These are not a pre-built binary. They are source-level inputs to the toolchain, just like user code. The pipeline treats them identically:
- Pascal runtime sources → p24p → .spc files
- Hand-coded .spc files pass through as-is
- pl24r concatenates all .spc files (app + user libs + runtime) into one merged .spc
- pa24r assembles the merged .spc into one .p24 binary
The runtime is statically included in every binary. For the COR24's constrained memory, this is appropriate — the runtime is small, and there is no OS to provide shared libraries.
The .spc (symbolic p-code) format is the human-readable assembly language
for the p-code VM. It currently supports:
; Comments
.data <name> <byte>, <byte>, ... ; data blocks
.global <name> <count> ; global variables
.proc <name> <nargs> ; procedure definition
.end ; end of procedure
<label>: ; code labels
<mnemonic> [operand] ; instructions
The .spc format is evolving to support modular compilation and
text-level linking. Planned directives (being worked on) include:
.module <name>-- declares the module name for this compilation unit.extern <symbol>-- references a symbol defined in another module.export <symbol>-- makes a symbol visible to other modules.import <module>-- declares a module dependency
These metadata directives enable pl24r to validate and resolve
cross-module references during text-level concatenation. By the time
the merged .spc reaches pa24r, all .extern references have been
matched to their .export definitions and the assembler sees a flat
symbol namespace with no unresolved references.
A simple binary container with a header, designed to be minimal now but extensible as the toolchain matures.
Offset Size Field
0x00 4 magic: "P24\0"
0x04 1 version (currently 1)
0x05 3 entry_point (offset into code segment)
0x08 3 code_size (bytes)
0x0B 3 data_size (bytes)
0x0E 3 global_count (number of 3-byte words)
0x11 1 flags (bit 0: has_debug)
0x12 ... code bytes
0x12+C ... data bytes
--- future sections ---
debug info (see Debug Information section)
The 3-byte (24-bit) fields match the COR24 word size. All multi-byte values are little-endian.
Since all linking and symbol resolution happens at the .spc text level, the .p24 format does not need relocation tables or import tables. Every address in the binary is a fully-resolved segment-relative offset.
The current approach (pvmasm.s) runs the p-code assembler inside the COR24 emulator, parsing .spc text at emulated instruction speed. Hello World takes ~14M COR24 instructions just to assemble and start executing. The assembler is valuable as a self-hosted test of the COR24 platform, but for interactive debugging, a fast loading path is essential.
- Feeds .spc source via UART to the integrated assembler+VM
- Assembler runs inside the emulator (~14M instructions for Hello World)
- Keep as a feature: useful for testing the COR24 platform and the assembler itself; the web debugger can offer this as a "run from source" mode
The Rust toolchain (pa24r) assembles .spc to .p24 at native speed. A loader module reads the .p24 header and writes code+data segments directly into the emulator's memory, initializes VM state registers (PC, ESP, CSP, GP, HP), and the VM begins executing immediately. Zero emulated instructions wasted on loading.
For the web debugger:
- Demos carry
include_bytes!("../demos/hello.p24")pre-assembled binaries - The loader patches emulator memory and initializes VM state
- Uses
pvm.s(simple VM, no assembler) as the host program - pvmasm.s remains available as a "run from source" mode
P-code instructions encode addresses as segment-relative offsets:
jmp,jz,jnz,call— offsets into the code segment. The VM addscode_baseat runtime when fetching.push <data-label>— offsets relative to the data segment base.push <global-label>— offsets relative to GP (globals pointer).
All addresses are segment-relative, making p-code effectively position-independent at the segment level. The loader places each segment anywhere in memory and sets the VM's base registers. No relocation table is needed in .p24.
Since all cross-module symbol resolution happens at the .spc text level (in pl24r), and pa24r assembles the fully-merged result, all offsets in the .p24 binary are final. Relocation is a concern for the linker and assembler, not the loader or binary format.
The loader needs to write into emulator memory, but the emulator should not depend directly on the p-code loader. The cleanest approach avoids circular dependencies entirely.
The loader crate (p24-loader or similar) depends on the .p24 format
definition but does not depend on cor24-rs. Instead, it exposes a
loaded image struct:
pub struct LoadedImage {
pub entry_point: u32,
pub code: Vec<u8>,
pub data: Vec<u8>,
pub global_count: u32,
}The consumer (web-dv24r, cor24-run, or a test harness) depends on both the loader crate and cor24-rs, and handles the actual memory patching:
p24-format (format definitions, no dependencies)
^
|
p24-loader (parses .p24, produces LoadedImage)
^
|
web-dv24r (depends on p24-loader + cor24-rs, patches memory)
cor24-run (same pattern)
This avoids any circular dependency. The emulator never knows about p-code. The loader never knows about the emulator. The application (web-dv24r) is the integration point that knows both.
If deeper integration is needed later (e.g., the loader needs to call emulator APIs during loading), a trait-based approach decouples the interface from the implementation:
// In p24-loader crate
pub trait MemoryTarget {
fn write_bytes(&mut self, addr: u32, data: &[u8]);
fn write_word(&mut self, addr: u32, value: u32);
}
// In web-dv24r (or cor24-rs)
impl MemoryTarget for EmulatorCore { ... }This is likely unnecessary given that all symbol resolution happens at the .spc level and the .p24 binary is self-contained, but remains an option if future requirements introduce load-time complexity.
-
pa24r: Create Rust p-code assembler (.spc -> .p24)
- Library crate with thin CLI wrapper
- Reuse or reference pasm.s opcode/mnemonic tables
- Two-pass assembly (same algorithm as pasm.s, but at native speed)
- Emit .p24 binary format
-
p24-loader: Create loader crate (or module within pa24r)
- Parse .p24 header, extract segments
- Return LoadedImage struct
-
web-dv24r integration: Add fast loading path
- Build demos with pa24r at build time (build.rs or pre-built)
- Loader patches emulator memory, initializes VM state
- Switch to pvm.s for fast-load demos
- Keep pvmasm.s as "run from source" option
-
. support: When p24p and pl24r implement module metadata
- Extend .spc with .module/.extern/.export/.import
- pl24r resolves cross-module references during text-level merge
- pa24r and .p24 format unchanged — they see only resolved symbols
Library-first design. The library is the core value — web-dv24r calls it from build.rs to pre-assemble demos, tests call it directly, and the CLI is a one-file wrapper for command-line use. This matches the pattern established by tc24r and cor24-rs.
P-code addresses are segment-relative offsets, not absolute memory addresses. The VM resolves them at runtime using base registers (code_base, GP, HP). This makes p-code effectively position-independent at the segment level. No relocation table needed in .p24.
All "relocation" work (adjusting offsets when merging modules) happens at the .spc text level in pl24r, or during assembly in pa24r's two-pass resolution. By the time .p24 is emitted, all offsets are final.
Cross-module symbol resolution is entirely a text-level concern handled
by pl24r. The linker concatenates .spc files and resolves .extern
references against .export definitions. The assembled result is one
flat .spc with no unresolved symbols. pa24r assembles it into a
self-contained .p24 with no import tables.
There is no binary linker in this design. The loader's only job is to place bytes in memory and initialize VM state. If a future use case requires load-time symbol resolution (e.g., dynamically loaded plugins), it could be added then, but it is not part of the current architecture.
The runtime (pr24p) is a set of Pascal source files and hand-coded .spc files. These are compiled/collected into .spc files and concatenated with application code by pl24r, just like any other module. The assembler (pa24r) produces one binary containing everything. The loader doesn't distinguish runtime code from application code.
The .p24 format should support optional debug information for the web debugger. This enables source-level debugging of Pascal programs: viewing Pascal source alongside p-code disassembly, inspecting named variables, and setting breakpoints by source line.
When the has_debug flag (bit 0) is set in the .p24 header, a debug
section follows the data segment:
Debug Section Header:
3 bytes debug_size (total size of debug section)
1 byte debug_version
Source File Table:
3 bytes file_count
For each file:
1 byte filename_length
N bytes filename (UTF-8)
Line Number Table:
3 bytes entry_count
For each entry:
3 bytes code_offset (byte offset into code segment)
2 bytes file_index (index into source file table)
2 bytes line_number
Symbol Table (local variables, parameters):
3 bytes symbol_count
For each symbol:
1 byte name_length
N bytes name (UTF-8)
1 byte kind (0=parameter, 1=local, 2=global)
3 bytes scope_start (code offset where symbol is in scope)
3 bytes scope_end
1 byte slot (frame offset for locals/params, global index)
Debug info flows through the toolchain:
- p24p (compiler) emits
.debug_lineand.debug_symdirectives in .spc output, annotating code with source locations and variable bindings - pl24r (linker) preserves debug directives during concatenation, adjusting file references as needed
- pa24r (assembler) collects debug directives during assembly and emits the debug section in .p24, mapping source locations to final code offsets
- p24-loader extracts the debug section from .p24 and provides it alongside the LoadedImage
- web-dv24r uses the debug info to show source lines, variable names, and support source-level breakpoints
With debug info available, the web debugger can offer:
- Source panel: show Pascal source with current line highlighted
- Variable inspector: display named locals and parameters with current values, instead of raw frame offsets
- Source breakpoints: click a Pascal source line to set a breakpoint at the corresponding p-code offset
- Step-by-source-line: step one Pascal statement at a time (may span multiple p-code instructions)
Debug info is optional — the debugger falls back to p-code disassembly with raw offsets when no debug section is present. This keeps the debugger useful for hand-coded .spc programs and release builds that strip debug info.