-
Methods and Functions in Meta-Assembler: A Practical Guide
02/22/2026 at 20:03 • 0 commentsIn previous posts, I discussed working with data containers. Now it's time to talk about methods and functions — the key elements of any program.
Method and Function Signatures
In the meta-assembler, every method and function has a strict signature that describes argument types and their passing registers. Let's look at a simple string copy procedure example:
lisp
(defproc test-copy-string ((dest string :reg de) (src string :reg hl)) (declare (once) (asm-func none)) ;; Input: DE = dest, HL = src (.label 'loop) (.ld a (@ hl)) ;; Read character from source (.ld (@ de) a) ;; Write to destination (.or a) ;; Check for 0 (end of string) (.ret 'z) ;; If 0 — exit (.inc hl) ;; Next in source (.inc de) ;; Next in destination (.jr 'loop)) ;; Repeat
As seen in the example, the procedure (a function without a class) declares:
- Argument names - for code readability
- Argument types - for correctness checking
- Passing registers - where each argument is expected on input
The return value is currently declared in the
(declare (asm-func none))block.Calling Conventions
The system follows these conventions:
Register AF/HL, DE, BC Object pointer For class methods IY Return value Function result Call Primitives
Several specialized primitives exist for calling procedures and methods:
1. Direct Function Call (
dcall)lisp
(dcall vec3::new int16 int16 _type_)
This call invokes the
newfunction from thevec3namespace. The compiler checks argument type compatibility (twoint16s) and expected result (_type_).Call variants:
- Via active table:
vec3::new— table can be anywhere - Via vtable in type:
vec3::vtable::new— explicit path specification - Direct call:
vec3::new— without a table, if the function is statically known
2. Binding Arguments to Registers (
rlet)The special form
rlet(or function definition macro) binds argument names to types and registers:lisp
(rlet ((x int16 hl) (y int16 de) (z int16 bc)) (dcall vec3::set x y z none))
The last line tells the compiler: call
vec3::setwith arguments x, y, z, which are already in their respective registers. The compiler only checks types and generates code:lisp
0147: CD 66 00 | CALL $0166
3. Object Method Call
Calling an object method requires an object pointer in the IX register. Two approaches are possible:
Sequential call with method loading into IY:
lisp
(obj-method->iy ix vec3::len) ;; Load method address into IY (ycall vec3::len vec3 len) ;; Call method via IY
Full method call cycle (with all overhead):
text
012B: DD 21 00 80 | LD IX, $8000 ; object address 012F: E5 | PUSH HL ; save registers 0130: C5 | PUSH BC 0131: DD 6E 00 | LD L, (IX+0) ; load vtable address 0134: DD 66 01 | LD H, (IX+1) 0137: 01 0F 00 | LD BC, $000F ; method offset 013A: 09 | ADD HL, BC ; get method address 013B: E5 | PUSH HL 013C: FD E1 | POP IY ; transfer to IY 013E: C1 | POP BC ; restore registers 013F: E1 | POP HL 0144: FD E5 | PUSH IY ; method address on stack 0146: C9 | RET ; call 0147: | [loc-exit] ; return point
If execution needs to continue after the call, the return address (in this case
loc-exit) is additionally pushed onto the stack.Performance: The Bitter Truth
I cannot avoid mentioning the elephant in the room. The compiler is written in an interpreter and, being quite complex, shows rather low performance. This raises legitimate doubts about its suitability for large projects.
Time will tell whether the system can scale or will require optimization of critical sections. For now, it's a working tool for medium-sized projects where the flexibility of metaprogramming outweighs compilation costs.
To be continued. In following posts: memory management and critical code optimization.
-
Subject: Runtime Type System on Z80: A Surprisingly Efficient Experimen
02/15/2026 at 21:29 • 0 commentsHi everyone, I wanted to share the results of a recent experiment. Initially, I didn’t plan on implementing deep typing, but while developing my own assembler (ZASM), a rather compact and powerful runtime type system started to crystallize.
The Concept: We define the type hierarchy directly in the source code. The .type-descriptor macro handles all the "heavy lifting": it generates pointers to parent types, creates string names for debugging, and most importantly, constructs vtables with full inheritance support.
Source Code (Lisp-style DSL):
(defproc test-type-descriptor () (declare (once) (asm-func none)) (.type-descriptor object) (.type-descriptor structure) (.type-descriptor vec3))
The Result (Generated Listing):
The system automatically generates linked data structures. Notice how the hierarchy is preserved: vec3 correctly points to the structure parent, which in turn points to the base object.
0051: 51 00 | DW ; Self-reference 0053: 60 00 | DW ; "object" string 0055: 02 | DB $02 ; Type flags/ID ... 0057: | [object::vtable::new] 0057: C3 01 00 | JP object::new ; Method dispatch ... 007A: | [type::def::vec3] 007A: 67 00 | DW ; Pointer to Parent! 0080: | [vec3::vtable::new] 0080: C3 05 00 | JP vec3::new ; Overridden method 0083: C3 02 00 | JP object::delete ; Inherited method
Why this works well:
1. Compactness: Each descriptor occupies only a few bytes.
2. Inheritance "for free": If a method isn't overridden in a child type, the vtable simply copies the jump (JP) to the parent's implementation.
3. Runtime Reflection: Having type names available at runtime allows for robust type-checking directly on the hardware.Next Steps: The final polish of the parse-arg logic for method argument validation. The goal is to have the assembler "slap the programmer's wrist" during compilation if they try to pass an incompatible register or type to a method.
-
Methods and Structures
02/11/2026 at 16:32 • 0 commentsIn bare metal assembly, structures don't exist. There are only addresses and offsets you keep in your head. Add a field at the start — now you get to hunt down every `LD (IX+2)` and turn it into `(IX+4)`. On a good day, you'll only miss a few. The debugger won't care.
We made structures real.
Here's how it works. Define the type:
```lisp
(deftype vec3 (structure)
((x int16)
(y int16)
(z int16))
(:methods
(clear () none)))
```Use it like you mean it:
```lisp
(defmethod clear ((self vec3))
(rlet ((v :reg ix :source self))
(zasm
(.ld (-> v x) 0)
(.ld (-> v y) 0)
(.ld (-> v z) 0))))
```No magic numbers. The language knows `y` lives at offset 2, and `ix` currently points at the object. Compiler plugs in the right addresses, spits this out:
```
DD 36 00 00 | LD (IX+0), $00
DD 36 02 00 | LD (IX+2), $00
DD 36 04 00 | LD (IX+4), $00
```**What you actually get:**
- **No offset bookkeeping.** Write `(-> v y)`, compiler handles the math.
- **Errors where they belong.** Typo in field name? Assembly fails right there, not after three hours of debugging.
- **Register independence.** Want `iy` instead of `ix`? Change one line in `rlet`, the whole block retargets.This isn't about making Z80 look like Lisp. It's about using macros to bridge the gap between how humans think about code and what the CPU actually executes. Names, types, checks — without leaving the metal.
-
Implementing VTables in a Meta-Assembler: From Type Declaration to Code Generation
02/08/2026 at 08:58 • 0 commentsI want to share some progress on my custom meta-assembler. The core focus here is automating the creation of Virtual Method Tables (VTables). This is essential when developing drivers or system components where data structures and methods must be tightly coupled but remain easily extensible.
1. Type Declaration (Layout)
At the source level, we define the data structure and method signatures. This allows the assembler to pre-calculate field offsets and method indices for the dispatch table.
Lisp
(deftype vec3 (structure) ((x int16) ;; offset 0 (y int16) ;; offset 2 (z int16)) ;; offset 4 (:methods (new (int16 int16) none) ;; Method ID 0 (len () int16))) ;; Method ID 12. Method Implementation
Next, we write the actual method code. By using the
$prefix, the meta-assembler automatically binds these labels to the specific type's method slots.Lisp
(org #x9000) $vec3::new (ret) $vec3::len (ret)3. VTable Generation
The
vtablecommand is a powerful meta-instruction. It inspects thevec3type, locates all implemented methods, and arranges them in the correct order. It also handles "holes" (sparse tables) if certain methods aren't overridden in a hierarchy.Lisp
(align 16) (vtable position vec3)Compilation Result (Listing)
The assembler doesn't just allocate space; it generates a functional jump table (using the
jpopcode0xC3for the Z80).Plaintext
9000 | -------- | org 9000h 9000 | | vec3::new: 9000 | C9 | ret 9001 | | vec3::len: 9001 | C9 | ret 9002 | -------- | align 10h 9010 | C3 00 90 00 00 00 00 00 | .vtable position, vec3, (quote default) 9010 | 00 00 00 00 00 00 00 00 | 9010 | 00 00 00 00 00 00 00 00 | 9010 | 00 00 00 C3 01 90 |
Key Benefits:
- Type Safety: Manually calculating table indices is error-prone; the assembler now handles this automatically.
- Flexibility: If you add a method to the
deftype, the VTable is rebuilt and re-aligned on the next pass. - Clarity: The listing clearly shows exactly where each jump leads, making debugging significantly easier.
Next step: Implementing high-level object declarations and automating method dispatch (dynamic dispatchers).
-
Type System Progress: Metadata is now baked into Z80 opcodes!
02/06/2026 at 19:07 • 0 commentsGreat progress on the compiler! I’ve finally bridged the gap between high-level type definitions and the Z80 assembler. The compiler now handles all offset and size calculations "under the hood," injecting pure constants directly into the generated instructions.
The core benefit:
No more manual byte-counting or maintaining a mess of
X_OFFSET = 2constants. You define the structure once, and from then on, you simply refer to fields by name directly withinzasmblocks.Example implementation:
We define a type and immediately use its metadata to generate assembly:
(deftype vector (structure) ((x int16) ;; offset 0 (y int16) ;; offset 2 (z int16)))) ;; offset 4 (zasm $begin (ld @hl (size-of vector)) ;; Get total structure size (ld @hl (offset-of vector x)) ;; Get offset of field 'x' (ld @hl (offset-of vector y)) ;; Get offset of field 'y' (ld @hl (size-of vector x)) ;; Get size of the type used by field 'x' (int16) (ld [+ iy (offset-of vector y)] 10) ;; Direct write to object field via IY )
Compilation Result (Binary Dump):
8000 | begin: | 8000 | 21 06 00 | ld hl, 06h ; Total size: 3 fields * 2 bytes 8003 | 21 00 00 | ld hl, 00h ; 'x' at offset 0 8006 | 21 02 00 | ld hl, 02h ; 'y' at offset 2 8009 | 21 02 00 | ld hl, 02h ; size of int16 = 2 800C | 21 02 00 | ld hl, 02h 800F | FD 36 02 0A | ld (iy+2), 0Ah ; Indexed write (IY+2) with value 10 (0Ah)
Why this matters:
- Refactoring Resilience: If I change a field from
int16toint32, the compiler automatically updates every offset across the entire project. - Native IY/IX Support: Support for
[+ iy displacement]syntax allows us to leverage the Z80’s indexed addressing mode as natively as possible. - Readability: The code feels like a modern systems language, but the output is honest, tight assembly.
Next steps:
Implementing instances (memory allocation for objects) and beginning the port of existing code to this new workflow. Finally, I can focus on logic without turning my brain into an offset calculator!
- Refactoring Resilience: If I change a field from
-
Z80 Assembler in Common Lisp is Taking Shape
02/05/2026 at 12:54 • 0 commentsThe project is gradually approaching a more-or-less complete form. Not everything works yet. The assembler itself was originally taken from a CL library, but in the process, it had to be rewritten from scratch. The previous version was good but overly convoluted: several levels of macros generated a large set of methods, each for one specific case (operation signature).
Quick Start
You need to add a standard initialization at the beginning of the source file. This can be done once per build session.
;; Load the assembler file (load "common/xiff/soot/asm.sot") ;; Switch the type system to Z80 (init-types 'z80)
Now Z80 data types are available, for example, uint16 or pointer (2 bytes in size).
Next, declare a module that will combine all segments into one logical block. This could be the entire program or a part of it. A module can consist of one or several segments, and segments can be written to one or more files.
(defmodule test-module)
Writing a Simple ProgramNow you can write a simple program.
(zasm (org #x0000) $zero (org #x8000) $start (jp 'label3) (db 22) (db (list 22 33 44)) (dw 64) (ds 8) (dw 'start) (dw '(1 2 3)) (ascii "Hello World\n") (align 16) $label1 (jmp 'label1) (jmp 'label2) (ld @a @b) $label2 (jmp 'label1) (jmp 'label3) (ld @c (+ 1 3)) (subroutine label3 (jmp 'label3) (jmp 'label2) (jmp 'label1) (ld @a @c) (jmp 0) (jmp 'zero) (ld @a [+ ix 5]) ; Should be: DD 7E 05 (ld [- iy 10] #xAA) ; Should be: FD 36 F6 AA ) )The org directive here is only for testing; in a real project, it might not be needed since each segment can have its own base address.
Finally, add the build directive:
(build test-module)
Running the BuildAfter launching:
> xiff Loaded asm-z80 ────────────────────────────────────────── BUILD SUCCESSFUL: test-module ────────────────────────────────────────── Segment: MAIN | Base: #0000 | Size: 32847 bytes ──────────────────────────────────────────
Generating a Listing
The following command saves a listing (with :print-listing #t, it also prints it to the screen):
(generate-module-listing test-module :print-listing #t)
The resulting listing:
--- Segment: MAIN | Base: #0000 --- 0000 | -------- | org 00h 0000 | zero: | 8000 | -------- | org 8000h 8000 | start: | 8000 | C3 3B 80 | jp label3 8003 | 16 | db 16h 8004 | 16 21 2C | db (22 33 44) 8007 | 40 00 | dw 40h 8009 | 00 00 00 00 00 00 00 00 | ds 08h 8011 | 00 80 | dw start 8013 | 01 00 02 00 03 00 | dw (1 2 3) 8019 | 48 65 6C 6C 6F 20 57 6F 72 6C 64 0A | ascii Hello World 8025 | -------- | align 10h 8030 | label1: | 8030 | 18 FE | jr label1 8032 | 18 01 | jr label2 8034 | 78 | ld a, b 8035 | label2: | 8035 | 18 F9 | jr label1 8037 | 18 02 | jr label3 8039 | 0E 04 | ld c, 04h 803B | label3: | 803B | 18 FE | jr label3 803D | 18 F6 | jr label2 803F | 18 EF | jr label1 8041 | 79 | ld a, c 8042 | C3 00 00 | jp 00h 8045 | C3 00 00 | jp zero 8048 | DD 7E 05 | ld a, (ix+5) 804B | FD 36 F6 AA | ld (iy-10), AAh
Exporting the Result
You can save all or selected segments to a file:
(save-module-hex test-module)
This creates two files.
1. Intel HEX format file:
:10800000C33B801616212C40000000000000000039 :1080100000008001000200030048656C6C6F20576F :108020006F726C640A000000000000000000000095 :1080300018FE18017818F918020E0418FE18F61820 :0F804000EF79C30000C30000DD7E05FD36F6AA10 :00000001FF
2. Symbol table file:("test-module" (:export label3 label2 label1 start zero) (:symbols (:public 32827 0 "label3" :label "MAIN" :meta ()) (:public 32821 0 "label2" :label "MAIN" :meta ()) (:public 32816 0 "label1" :label "MAIN" :meta ()) (:public 32768 0 "start" :label "MAIN" :meta ()) (:public 0 0 "zero" :label "MAIN" :meta ()) ) )What's Next?
The data structures can already be saved to a buffer, but they cannot yet be used directly in the assembler. I hope this functionality will be working within a week.
To be continued...
-
Implementing Dot-Navigation (Introspection) for the Type System
01/29/2026 at 15:48 • 0 commentsToday marks a major milestone: full dot-navigation support via the
->operator is now operational. The engine can now "traverse" the type metadata hierarchy, seamlessly moving from the global registry down to specific fields and their internal attributes.The Goal
Once a type is declared in Lisp (for example, a structure):
(deftype test-vector (basic) ((x int8 1) (y int8 2)) (:methods (new (int8 int8) object) (set (int8 int8) none) (len () int8)))We needed a way to inspect this type using dot notation. This isn't just manipulating Lisp lists; it is Runtime Reflection over native C++ objects, exposed to the interpreter as NATIVE_REF.
REPL Demonstration
First, we can access the global
*type-system*registry. For instance, we can query the pointer size for the current target architecture (in this case, Z80):soot> (-> *type-system* pointer-size) => 2When querying the structure itself, the system retrieves the BasicType object, which contains vectors for fields and methods:
soot> (-> *type-system* test-vector) => [BasicType] test-vector parent: basic size: 5 fields: Field: (type type :offset 0) ... Field: (x int8 :offset 2) ... Field: (y int8 :offset 3) ... methods: Method 0: new (function int8 int8 object) Method 1: set (function int8 int8 none) Method 2: len (function int8)The -> operator is implemented as a Special Form in C++, allowing us to build deep chains without excessive quoting. We can "drill down" into a specific field to extract its native offset:
;; Access the 'x' field object soot> (-> *type-system* test-vector x) => Field: (x int8 :offset 2) ... ;; Extract a specific attribute (offset) from that field soot> (-> *type-system* test-vector x offset) => 2
A key design detail: navigation respects object boundaries. A field returns a TypeSpec (a type specification/reference), not the type definition object itself. To find the parameters of the underlying data type, we perform a lookup back through the type system:
;; Get the type specification of the field soot> (-> *type-system* test-vector x type) => int8 ;; Resolve that spec to the actual type definition to get its size soot> (-> *type-system* int8 size) => 1
Under the Hood
This navigation is powered by the `Aliasable` system:
- Chain of Responsibility: Virtual make_step_alias calls propagate correctly through the inheritance hierarchy (from Aliasable through StructureType to BasicType), ensuring data access is never lost at different levels of inheritance.
- Unified Interface: The C++ step(key) method handles both meta-property lookups (like size)
- Architecture Agnostic: The system dynamically pulls data based on the initialized architecture (e.g., Z80 vs. Default).
What's Next?
With the code stabilized, we have a solid foundation for the next step: the Static Data Compiler. Now that we can programmatically "see" the offsets and types of every field via introspection, we can begin automating the assembly of structures, constants, and method tables directly into binary buffers.
-
Moving from C/ASM Drivers to a Custom Lisp-Based Assembler with a Type System
01/26/2026 at 21:28 • 0 commentsI’d like to share my experience developing system software for a retro platform (Z80).
The Problem: Synchronization Hell
It all started with the driver specifications. Initially, I implemented them using a mix of C and ASM. After completing the core drivers, the sheer scale of the task became clear: the platform's numerous modes required a massive amount of redundant manual work.
Worst of all was maintaining consistency. Any minor change to a data structure had to be manually propagated through the entire chain:
.h->.c->.inc->.asm. Field offsets would constantly drift, and keeping track of register assignments and method arguments became a nightmare.The Solution: From Annotations to Metaprogramming
My first idea was to procedurally generate files from special annotations within the assembly code (which led to the XIFF specification). During development, the annotation syntax naturally evolved toward S-expressions.
That’s when I asked myself: Why use annotations at all when I can write everything in Lisp? This allows for true metaprogramming—using Lisp macros to create a flexible DSL that describes the driver logic itself.
The Experiment: An Assembler on SOOT
As an experiment, I wrote a Z80 assembler on SOOT (my dialect of Lisp). It allows the power of macros to be used directly within the program body.
Source Code Example:
Lisp
(defmacro subroutine (name &rest code) `(list (lab ',name) ,@code)) (defparameter test (lzasm-z80 $bla (jmp 'blo) (ld @a @b) $bli (jmp 'blo) (ld @c (+ 1 3)) ; Compile-time math $ble (ld @c (+ 1 3)) (ld @c (+ 1 3)) $blu (ld @a @c) $blo (subroutine blubber (ld @a @c)) (jmp 'blo) (jmp 'blu)))Compilation Result (The system automatically handles
jpvsjroptimization and calculates offsets):Plaintext
0000 | bla: | 0000 | 18 0A | jr blo 0002 | 78 | ld a, b 0003 | bli: | 0003 | 18 07 | jr blo 0005 | 0E 04 | ld c, 4 0007 | ble: | 0007 | 0E 04 | ld c, 4 0009 | 0E 04 | ld c, 4 000B | blu: | 000B | 79 | ld a, c 000C | blo: | 000C | blubber: | 000C | 79 | ld a, c 000D | 18 FD | jr blo 000F | 18 FA | jr blu
I prioritized diagnostics. The system provides the exact error location in the source file, which is critical when using heavy macro generation:
Plaintext
─── ERROR ────────────────────────────────── at common/xiff/soot/asm-test.sot:48 (jmp 'blo-undefined) ^ Error: Can't find label blo-undefinedThe Next Step: Integrating the Type System
I am currently working on integrating a type system. This allows for declaring
enums,bitfields,structures, and evenclass methodsdirectly in the code.Type Declaration:
Lisp
(deftype test-vector (basic) ((x int8 1) (y int8 2)) (:methods (new (int8 int8) object) (set (int8 int8) none) (len () int8)))Now, the compiler environment has full introspection—it knows sizes, hierarchies, and field offsets adjusted for alignment:
Lisp
soot> (type-info '(test-vector)) => ((name . "test-vector") (size . 5) ;; 2 bytes for type header + fields (fields ("y" 3 int8 ...) ("x" 2 int8 ...) ("type" 0 type ...)) (methods ("len" 2 ...) ("set" 1 ...)) ...)The Goal: To enable the assembler to work with structures directly via access macros. Instead of manual offset calculation (magic numbers), the code should look like this:
Lisp
(ld a (ix (-> cursor x))) (ld l (ix (-> cursor y)))
There is still a lot of work to do to bridge the type system and the code generator before I attempt to migrate the actual driver code to this new design and build system. However, early tests show that this approach radically reduces "human factor" errors.
-
Aleste LX/BIOS Driver Architecture Specification (Draft)
01/08/2026 at 17:15 • 0 commentsFollowing work on the Aleste LX hardware and its BIOS, a draft driver architecture specification has been compiled.
The document describes a driver architecture for 8-bit banked memory systems, built on the "speed through predictability" principle. Solutions are focused on practical hardware work where every clock cycle counts.
Key features:
- Direct driver call: 17 cycles (JP + RET)
- Zero-overhead polymorphism via VTable copying
- Banked memory management with lazy switching
- Clear separation between singleton and polymorphic drivers
The specification provides direction for developing drivers for the platform: video, audio, filesystems.
Read the specification (draft) -
Alesta XI/LX: Software Simulator
12/31/2025 at 15:44 • 0 commentsTo debug the new BIOS and FPGA tests, we had to develop another software module for the entire system — a simple software simulator of the target Alesta LX platform. This entire lengthy development cycle was necessary to ultimately streamline the entire process.
The initial task was to create embedded tests for FPGA debugging, which must run on the hardware platform itself. For these to work, in turn, we needed to write a new BIOS from scratch for about 90% of its code. This created a key problem: how to debug this very BIOS if the physical platform for testing was not yet available?
The decision was made to break this vicious cycle by creating an emulator — a software replacement for the hardware. The development process is now structured into a clear sequence:
- The simulator itself is debugged on the main (host) platform. It is quite simple and has built-in debugging tools.
- Using this functional simulator as a "test bench," the new BIOS is debugged.
- Then, on the debugged BIOS, the tests for the target FPGA are verified and finalized.
This entire toolchain is first debugged and run in simulation. Only when all components work together correctly do we move on to testing on real hardware. From the outside, this may seem like a complex, multi-stage path, but in reality, it is the most efficient and reliable option possible.
The image below shows the Alesta LX simulator in operation during a debugging session. The next step will be the debugging of the new BIOS itself.
Happy upcoming New Year to everyone!
![]()
h2w