commit de636ed71441d5dd25d5e73eb0bc1cb808acdc17
parent fa9eb5967ef6ac61d3711e3687f7b2ffdbaf8767
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Thu, 23 Apr 2026 14:45:59 -0700
m1pp: Phase 2 — store %macro definitions in arenas
Replace the structural %macro/%endm skip in m1pp.M1 with a real
define_macro that parses the header (name, comma-separated params,
trailing newline) and copies body tokens into macro_body_tokens[]
until a line-start %endm. Records live in a 32-slot macros[] arena
(296 B/record) with running tail pointers. Macros are still not
invoked; defs-only input continues to match the C oracle byte-for-byte.
Adds tests/m1pp/02-defs.{M1pp,expected} as the Phase 2 parity fixture.
Diffstat:
4 files changed, 428 insertions(+), 67 deletions(-)
diff --git a/docs/M1M-IMPL.md b/docs/M1M-IMPL.md
@@ -487,11 +487,14 @@ symbol name.
plus `append_text_len`, `push_token`, `token_text_eq`,
`span_eq_token`.
-- [ ] **Phase 2 — Macro definition storage.**
- Replace structural skipping with real storage: parse header,
- params, body tokens, body limits, line-start `%endm`. Does not
- yet call macros — adding defs-only input to an otherwise
- pass-through run must still match the oracle.
+- [x] **Phase 2 — Macro definition storage.**
+ Replaced structural skipping with real storage: `define_macro`
+ parses the header (name, params with comma splits, trailing
+ newline) and copies body tokens into `macro_body_tokens[]` until
+ a line-start `%endm`. Records land in a 32-slot `macros[]` arena
+ (296 B/record). Macros are not yet called — defs-only input
+ matches the oracle. `find_macro` / `find_param` deferred to the
+ phases that exercise them (Phase 5).
Oracle: `define_macro`, `find_macro`, `find_param`.
- [ ] **Phase 3 — Stream stack + expansion-pool lifetime.**
diff --git a/m1pp/m1pp.M1 b/m1pp/m1pp.M1
@@ -2,8 +2,9 @@
##
## Runtime shape: m1pp input.M1 output.M1
##
-## Phase 1: lexer + pass-through with structural %macro/%endm skip.
-## Behavior mirrors m1pp/m1pp.c (the oracle) for definition-only inputs.
+## Phase 2: lexer + pass-through + real %macro storage (header, params,
+## body tokens, body limits). Macros are stored but not yet called, so
+## definition-only inputs match the C oracle byte-for-byte.
##
## Pipeline:
## _start argv/argc from kernel SP; openat+read into input_buf
@@ -12,9 +13,10 @@
## -> openat+write output_buf to argv[2]; exit
## lex_source input_buf -> source_tokens[] (via append_text + push_source_token)
## process_tokens source_tokens[] -> output_buf (via emit_token / emit_newline,
-## branching to skip_macro_def at
+## branching to define_macro at
## line-start %macro)
-## skip_macro_def structural consume through %endm; no tokens emitted
+## define_macro parse %macro header+body; record in macros[] + macro_body_tokens[];
+## consume through the %endm line without emitting output
##
## P1v2 ABI: a0..a3 arg/return, t0..t2 caller-saved temps, s0..s3 callee-saved
## (unused here). Non-leaf functions use enter_0 / leave. _start has no frame;
@@ -26,6 +28,14 @@ DEFINE M1PP_INPUT_CAP 0020000000000000
DEFINE M1PP_OUTPUT_CAP 0020000000000000
DEFINE M1PP_TEXT_CAP 0010000000000000
DEFINE M1PP_TOKENS_END 0018000000000000
+## Macro record is 296 bytes: name (16) + param_count (8) + params[16]*16 (256)
+## + body_start (8) + body_end (8). 32 macros × 296 = 9472 bytes = MACROS_CAP.
+## Body-token arena: 256 × 24 = 6144 bytes = MACRO_BODY_CAP.
+DEFINE M1PP_MACRO_RECORD_SIZE 2801000000000000
+DEFINE M1PP_MACRO_BODY_START_OFF 1801000000000000
+DEFINE M1PP_MACRO_BODY_END_OFF 2001000000000000
+DEFINE M1PP_MACROS_CAP 0025000000000000
+DEFINE M1PP_MACRO_BODY_CAP 0018000000000000
DEFINE O_WRONLY_CREAT_TRUNC 4102000000000000
DEFINE MODE_0644 A401000000000000
DEFINE AT_FDCWD 9CFFFFFFFFFFFFFF
@@ -58,6 +68,14 @@ DEFINE TOK_PASTE 0600000000000000
la_a1 &source_end
st_a0,a1,0
+ # macros_end = ¯os; macro_body_end = ¯o_body_tokens
+ la_a0 ¯os
+ la_a1 ¯os_end
+ st_a0,a1,0
+ la_a0 ¯o_body_tokens
+ la_a1 ¯o_body_end
+ st_a0,a1,0
+
# input_fd = openat(AT_FDCWD, argv[1], O_RDONLY, 0)
li_a0 sys_openat
li_a1 AT_FDCWD
@@ -744,8 +762,8 @@ DEFINE TOK_PASTE 0600000000000000
la_br &process_not_macro
beqz_a0
- # line-start %macro -> consume the definition
- la_br &skip_macro_def
+ # line-start %macro -> record the definition
+ la_br &define_macro
call
la_br &process_loop
b
@@ -794,111 +812,326 @@ DEFINE TOK_PASTE 0600000000000000
leave
ret
-## --- Structural %macro skip (placeholder for Phase 2's define_macro) ---------
-## Consumes the macro header, body, and %endm line without emitting anything
-## or recording the definition. Phase 2 replaces this with real storage of
-## the macro's name, params, and body tokens.
-
-## skip_macro_def(): proc_pos at %macro. Leaves proc_pos after %endm line.
-:skip_macro_def
+## --- %macro storage: parse header + body into macros[] / macro_body_tokens --
+## Called at proc_pos == line-start `%macro`. Leaves proc_pos past the %endm
+## line with proc_line_start = 1. Uses BSS scratch (def_m_ptr, def_param_ptr,
+## def_body_line_start) since P1v2 enter/leave does not save s* registers.
+##
+## Macro record layout (296 bytes, see M1PP_MACRO_RECORD_SIZE):
+## +0 name.ptr (8)
+## +8 name.len (8)
+## +16 param_count (8)
+## +24 params[16].ptr/.len (16 * 16 = 256)
+## +280 body_start (8) -> *Token into macro_body_tokens[]
+## +288 body_end (8) -> exclusive end
+
+## define_macro(): consume `%macro NAME(params...)\n ... %endm\n`.
+:define_macro
enter_0
- # consume the %macro token itself, then scan with our own line_start bit
+ # macros_end bounds check: if (macros_end == ¯os + MACROS_CAP) fatal
+ la_a0 ¯os_end
+ ld_t0,a0,0
+ la_a1 ¯os
+ li_a2 M1PP_MACROS_CAP
+ add_a1,a1,a2
+ la_br &err_too_many_macros
+ beq_t0,a1
+
+ # def_m_ptr = macros_end (Macro *m = ¯os[macro_count])
+ la_a1 &def_m_ptr
+ st_t0,a1,0
+
+ # advance past the %macro token itself
la_a0 &proc_pos
ld_t0,a0,0
addi_t0,t0,24
st_t0,a0,0
- la_a0 &skip_line_start
- li_a1 %0 %0
- st_a1,a0,0
-:skip_macro_loop
- # tok = proc_pos; if (tok == source_end) fatal (unterminated)
+ # ---- header: name (WORD) ----
+ la_a1 &source_end
+ ld_t1,a1,0
+ la_br &err_bad_macro_header
+ beq_t0,t1
+ ld_a1,t0,0
+ li_a2 TOK_WORD
+ la_br &err_bad_macro_header
+ bne_a1,a2
+
+ # m->name.ptr = tok->text_ptr; m->name.len = tok->text_len
+ ld_a2,t0,8
+ ld_a3,t0,16
+ la_a0 &def_m_ptr
+ ld_t2,a0,0
+ st_a2,t2,0
+ st_a3,t2,8
+
+ # m->param_count = 0; def_param_ptr = m + 24 (first TextSpan slot)
+ li_a0 %0 %0
+ st_a0,t2,16
+ addi_t2,t2,24
+ la_a0 &def_param_ptr
+ st_t2,a0,0
+
+ # advance past name
+ addi_t0,t0,24
la_a0 &proc_pos
- ld_t0,a0,0
+ st_t0,a0,0
+
+ # ---- header: LPAREN ----
la_a1 &source_end
ld_t1,a1,0
- la_br &err_unterminated_macro
+ la_br &err_bad_macro_header
beq_t0,t1
+ ld_a1,t0,0
+ li_a2 TOK_LPAREN
+ la_br &err_bad_macro_header
+ bne_a1,a2
- # if (!skip_line_start) just advance past tok
- la_a0 &skip_line_start
+ # advance past '('
+ addi_t0,t0,24
+ la_a0 &proc_pos
+ st_t0,a0,0
+
+ # ---- header: optional param list ----
+ # if at end -> fall through to RPAREN check (which will fail)
+ # if next is RPAREN -> skip the param loop
+ # else enter param loop
+ la_a1 &source_end
+ ld_t1,a1,0
+ la_br &def_header_close
+ beq_t0,t1
+ ld_a1,t0,0
+ li_a2 TOK_RPAREN
+ la_br &def_header_close
+ beq_a1,a2
+
+:def_param_loop
+ # reject > 16 params: if (15 < param_count) fail (param_count capped at 16)
+ la_a0 &def_m_ptr
ld_t2,a0,0
- la_br &skip_check_advance
- beqz_t2
+ ld_a1,t2,16
+ li_a2 %15 %0
+ la_br &err_bad_macro_header
+ blt_a2,a1
- # if (tok->kind != TOK_WORD) just advance
+ # tok must be in range and WORD
+ la_a0 &proc_pos
+ ld_t0,a0,0
+ la_a1 &source_end
+ ld_t1,a1,0
+ la_br &err_bad_macro_header
+ beq_t0,t1
ld_a1,t0,0
li_a2 TOK_WORD
- la_br &skip_check_advance
+ la_br &err_bad_macro_header
bne_a1,a2
- # if (!tok_eq_const(tok, "%endm", 5)) just advance
- mov_a0,t0
- la_a1 &const_endm
- li_a2 %5 %0
- la_br &tok_eq_const
- call
- la_br &skip_check_advance
- beqz_a0
+ # *def_param_ptr = (tok.text_ptr, tok.text_len); def_param_ptr += 16
+ ld_a2,t0,8
+ ld_a3,t0,16
+ la_a0 &def_param_ptr
+ ld_t1,a0,0
+ st_a2,t1,0
+ st_a3,t1,8
+ addi_t1,t1,16
+ st_t1,a0,0
- # matched %endm at line start -- fall through to skip_to_line_end
+ # m->param_count++
+ la_a0 &def_m_ptr
+ ld_t2,a0,0
+ ld_a1,t2,16
+ addi_a1,a1,1
+ st_a1,t2,16
-:skip_to_line_end
- # walk to the first newline (inclusive) and stop
+ # advance past the param word
+ addi_t0,t0,24
+ la_a0 &proc_pos
+ st_t0,a0,0
+
+ # if next is COMMA, consume and loop; else break
+ la_a1 &source_end
+ ld_t1,a1,0
+ la_br &def_header_close
+ beq_t0,t1
+ ld_a1,t0,0
+ li_a2 TOK_COMMA
+ la_br &def_header_close
+ bne_a1,a2
+ addi_t0,t0,24
+ la_a0 &proc_pos
+ st_t0,a0,0
+ la_br &def_param_loop
+ b
+
+:def_header_close
+ # ---- header: RPAREN ----
la_a0 &proc_pos
ld_t0,a0,0
la_a1 &source_end
ld_t1,a1,0
- la_br &skip_done_at_eof
+ la_br &err_bad_macro_header
+ beq_t0,t1
+ ld_a1,t0,0
+ li_a2 TOK_RPAREN
+ la_br &err_bad_macro_header
+ bne_a1,a2
+
+ addi_t0,t0,24
+ la_a0 &proc_pos
+ st_t0,a0,0
+
+ # ---- header: terminating NEWLINE ----
+ la_a1 &source_end
+ ld_t1,a1,0
+ la_br &err_bad_macro_header
beq_t0,t1
ld_a1,t0,0
li_a2 TOK_NEWLINE
+ la_br &err_bad_macro_header
+ bne_a1,a2
+
addi_t0,t0,24
la_a0 &proc_pos
st_t0,a0,0
- la_br &skip_done_after_newline
- beq_a1,a2
- la_br &skip_to_line_end
- b
-:skip_done_after_newline
-:skip_done_at_eof
- # caller resumes at line start
- la_a0 &proc_line_start
+ # ---- body: m->body_start = macro_body_end; body_line_start = 1 ----
+ la_a1 ¯o_body_end
+ ld_t2,a1,0
+ la_a0 &def_m_ptr
+ ld_t1,a0,0
+ li_a0 M1PP_MACRO_BODY_START_OFF
+ add_a0,t1,a0
+ st_t2,a0,0
+ la_a0 &def_body_line_start
li_a1 %1 %0
st_a1,a0,0
- leave
- ret
-:skip_check_advance
- # update skip_line_start based on tok->kind, then bump proc_pos
+:def_body_loop
+ # if proc_pos == source_end: unterminated %macro
la_a0 &proc_pos
ld_t0,a0,0
+ la_a1 &source_end
+ ld_t1,a1,0
+ la_br &err_unterminated_macro
+ beq_t0,t1
+
+ # if (!body_line_start) copy token
+ la_a0 &def_body_line_start
+ ld_t2,a0,0
+ la_br &def_body_copy
+ beqz_t2
+
+ # if (tok.kind != TOK_WORD) copy token
ld_a1,t0,0
- li_a2 TOK_NEWLINE
- la_br &skip_set_not_line_start
+ li_a2 TOK_WORD
+ la_br &def_body_copy
bne_a1,a2
- # tok is TOK_NEWLINE: next iteration is at line start
- la_a0 &skip_line_start
+ # if (!tok_eq_const(tok, "%endm", 5)) copy token
+ mov_a0,t0
+ la_a1 &const_endm
+ li_a2 %5 %0
+ la_br &tok_eq_const
+ call
+ la_br &def_body_copy
+ beqz_a0
+
+ # matched %endm at line start -> skip to end of the line, then finish
+ la_br &def_endm_skip_to_newline
+ b
+
+:def_body_copy
+ # bounds: if (macro_body_end - macro_body_tokens + 24 > MACRO_BODY_CAP) fail
+ la_a0 ¯o_body_end
+ ld_t1,a0,0
+ la_a2 ¯o_body_tokens
+ sub_a3,t1,a2
+ addi_a3,a3,24
+ li_t2 M1PP_MACRO_BODY_CAP
+ la_br &err_macro_body_overflow
+ blt_t2,a3
+
+ # copy 24 bytes from *proc_pos to *macro_body_end
+ la_a0 &proc_pos
+ ld_t0,a0,0
+ ld_a1,t0,0
+ st_a1,t1,0
+ ld_a1,t0,8
+ st_a1,t1,8
+ ld_a1,t0,16
+ st_a1,t1,16
+
+ # macro_body_end += 24
+ addi_t1,t1,24
+ la_a0 ¯o_body_end
+ st_t1,a0,0
+
+ # body_line_start = (tok.kind == TOK_NEWLINE)
+ ld_a1,t0,0
+ li_a2 TOK_NEWLINE
+ la_br &def_body_clear_ls
+ bne_a1,a2
+ la_a0 &def_body_line_start
li_a1 %1 %0
st_a1,a0,0
- la_br &skip_inc_pos
+ la_br &def_body_advance
b
-:skip_set_not_line_start
- la_a0 &skip_line_start
+:def_body_clear_ls
+ la_a0 &def_body_line_start
li_a1 %0 %0
st_a1,a0,0
-:skip_inc_pos
- # proc_pos++
+:def_body_advance
+ # proc_pos += 24
la_a0 &proc_pos
ld_t0,a0,0
addi_t0,t0,24
st_t0,a0,0
- la_br &skip_macro_loop
+ la_br &def_body_loop
+ b
+
+:def_endm_skip_to_newline
+ # consume tokens through the first NEWLINE (inclusive); tolerate EOF
+ la_a0 &proc_pos
+ ld_t0,a0,0
+ la_a1 &source_end
+ ld_t1,a1,0
+ la_br &def_finish
+ beq_t0,t1
+ ld_a1,t0,0
+ li_a2 TOK_NEWLINE
+ addi_t0,t0,24
+ la_a0 &proc_pos
+ st_t0,a0,0
+ la_br &def_finish
+ beq_a1,a2
+ la_br &def_endm_skip_to_newline
b
+:def_finish
+ # m->body_end = macro_body_end
+ la_a1 ¯o_body_end
+ ld_t2,a1,0
+ la_a0 &def_m_ptr
+ ld_t1,a0,0
+ li_a0 M1PP_MACRO_BODY_END_OFF
+ add_a0,t1,a0
+ st_t2,a0,0
+
+ # macros_end += MACRO_RECORD_SIZE
+ la_a0 ¯os_end
+ ld_t0,a0,0
+ li_a1 M1PP_MACRO_RECORD_SIZE
+ add_t0,t0,a1
+ st_t0,a0,0
+
+ # caller resumes at line start
+ la_a0 &proc_line_start
+ li_a1 %1 %0
+ st_a1,a0,0
+ leave
+ ret
+
## --- Error paths -------------------------------------------------------------
## Each err_* loads a (msg, len) pair for fatal; fatal writes "m1pp: <msg>\n"
## to stderr and exits 1. Error labels are branched to from range/overflow
@@ -954,6 +1187,21 @@ DEFINE TOK_PASTE 0600000000000000
li_a1 %30 %0
la_br &fatal
b
+:err_bad_macro_header
+ la_a0 &msg_bad_macro_header
+ li_a1 %16 %0
+ la_br &fatal
+ b
+:err_too_many_macros
+ la_a0 &msg_too_many_macros
+ li_a1 %15 %0
+ la_br &fatal
+ b
+:err_macro_body_overflow
+ la_a0 &msg_macro_body_overflow
+ li_a1 %19 %0
+ la_br &fatal
+ b
## fatal(a0=msg_ptr, a1=msg_len): writes "m1pp: <msg>\n" to stderr, exits 1.
## Saves args across the three syscalls since a0..a3 are caller-saved.
@@ -1014,6 +1262,9 @@ DEFINE TOK_PASTE 0600000000000000
:msg_token_overflow "token buffer overflow"
:msg_output_overflow "output buffer overflow"
:msg_unterminated_macro "unterminated %macro definition"
+:msg_bad_macro_header "bad macro header"
+:msg_too_many_macros "too many macros"
+:msg_macro_body_overflow "macro body overflow"
## --- BSS ---------------------------------------------------------------------
## Placed before :ELF_end so filesz/memsz (which this ELF header sets equal)
@@ -1053,7 +1304,15 @@ ZERO32
ZERO32
:proc_line_start
ZERO32
-:skip_line_start
+:macros_end
+ZERO32
+:macro_body_end
+ZERO32
+:def_m_ptr
+ZERO32
+:def_param_ptr
+ZERO32
+:def_body_line_start
ZERO32
:err_saved_msg
ZERO32
@@ -1176,4 +1435,74 @@ ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+## macros: 32 records × 296 bytes = 9472 bytes (M1PP_MACROS_CAP).
+## 37 lines × 256 bytes = 9472. Each line is 8 × ZERO32 = 256 bytes.
+:macros
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+
+## macro_body_tokens: 256 slots × 24 bytes = 6 KB (M1PP_MACRO_BODY_CAP).
+## 24 lines × 256 bytes = 6144. Source tokens are copied in 24 bytes at a
+## time as macro bodies are recorded.
+:macro_body_tokens
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32 ZERO32
+
:ELF_end
diff --git a/tests/m1pp/02-defs.M1pp b/tests/m1pp/02-defs.M1pp
@@ -0,0 +1,21 @@
+## Phase 2 parity fixture: %macro definitions are stored, not invoked.
+## Defs produce no output; non-def tokens pass through as in Phase 1.
+## Exercises: 0/1/many params, multi-token bodies, string body tokens,
+## body-internal ## paste, %macro-looking words mid-line, empty body.
+before
+%macro A()
+ body one
+ body two
+%endm
+middle
+%macro B(x)
+ x before ## x after
+ "string in body"
+%endm
+not %macro at line start
+%macro C(p, q, r)
+ p q r
+%endm
+%macro EMPTY(x)
+%endm
+last
diff --git a/tests/m1pp/02-defs.expected b/tests/m1pp/02-defs.expected
@@ -0,0 +1,8 @@
+## Phase 2 parity fixture: %macro definitions are stored , not invoked.
+## Defs produce no output
+## Exercises: 0/1/many params , multi-token bodies , string body tokens ,
+## body-internal ## paste , %macro-looking words mid-line , empty body.
+before
+middle
+not %macro at line start
+last