boot2

Playing with the boostrap
git clone https://git.ryansepassi.com/git/boot2.git
Log | Files | Refs | README

M1pp.P1 (173944B)


      1 ## m1pp.M1 — bootstrap M1 macro-expander, P1.
      2 ##
      3 ## Runtime shape: m1pp input.M1 output.M1
      4 ##
      5 ## Pipeline:
      6 ##   p1_main         argc/argv from the backend :_start stub; stash argv[1..2]
      7 ##                   into input_path / output_path; openat+read into
      8 ##                   input_buf; call lex_source, then process_tokens;
      9 ##                   openat+write output_buf to output_path; return 0.
     10 ##   lex_source      input_buf -> source_tokens[] (via append_text +
     11 ##                   push_source_token).
     12 ##   process_tokens  Stream-driven loop. Pushes source_tokens as the initial
     13 ##                   stream and walks it token-by-token, dispatching to
     14 ##                   define_macro at line-start %macro, emit_newline /
     15 ##                   emit_token for pass-through, expand_builtin_call for
     16 ##                   !@%$, %select, %str, %local, %reset-output, and expand_call
     17 ##                   for user macros. Macro expansions and %select push
     18 ##                   fresh streams onto streams[]; popping rewinds the
     19 ##                   expansion pool.
     20 ##
     21 ## Output is consumed directly by hex2pp -- there is no intermediate M0/hex2
     22 ## stage. Lexical scoping for control-flow labels is delegated to hex2pp's
     23 ## nestable .scope / .endscope; M1pp itself only handles per-expansion
     24 ## macro hygiene labels (:@name / &@name).
     25 ##   define_macro    Parse %macro header+body; record in macros[] +
     26 ##                   macro_body_tokens[]; consume through the %endm line
     27 ##                   without emitting output.
     28 ##
     29 ## P1 ABI: a0..a3 arg/return, t0..t2 caller-saved temps, s0..s3 callee-saved
     30 ## (unused here). Non-leaf functions use enter_0 / eret. Entry is the portable
     31 ## p1_main (a0=argc, a1=argv); the backend-owned :_start stub captures
     32 ## argc/argv from the native entry state and sys_exits p1_main's return value.
     33 
     34 ## --- Constants & sizing ------------------------------------------------------
     35 
     36 ## Caps bumped 2025-04 for tcc-boot2: cc.scm against the flattened
     37 ## tcc.flat.c emits ~6.5 MB of macro-rich .P1pp; m1pp's old caps were
     38 ## sized for the scheme1 build at ~256 KiB. New caps cover the larger
     39 ## working set the cc-bootstrap path exercises:
     40 ##
     41 ##   16 MiB input        — combined prelude + tcc.flat.P1pp
     42 ##  128 MiB output       — final .M1 (~8x source after expansion)
     43 ##   64 MiB text-arena   — pasted names, hex literals from %(...) eval,
     44 ##                         per-call @local label rewrites (cc.scm
     45 ##                         triggers ~hundreds of K of each)
     46 ##  256 MiB source-toks  — 8 M Token slots × 32 B
     47 DEFINE M1PP_INPUT_CAP 0000000100000000
     48 DEFINE M1PP_OUTPUT_CAP 0000000800000000
     49 DEFINE M1PP_TEXT_CAP 0000000400000000
     50 ## source_tokens cap: 256 MB / 32-byte tokens = ~8.4 M tokens. cc.scm
     51 ## emits one source token per ~3 input bytes (heavy macro use), so a
     52 ## 6.5 MB tcc.flat.P1pp tokenises to ~2.5 M; 8 M leaves comfortable
     53 ## headroom for the larger TUs the harness will exercise next.
     54 DEFINE M1PP_TOKENS_END 0000001000000000
     55 ## Macro record is 304 bytes: name (16) + param_count (8) + params[16]*16 (256)
     56 ## + body_start (8) + body_end (8) + has_paste (8). MACROS_CAP fits 1024
     57 ## records (311296 B). The 2x BSS slot for macros (0x94000 B) still has
     58 ## ample headroom past the new cap. has_paste is a 0/1 flag set during
     59 ## define_macro when the body contains TOK_PASTE; expand_macro_tokens
     60 ## reads it (along with args_have_paste from parse_args) to skip the
     61 ## paste_pool_range scan when neither side contributes a `##`.
     62 ## Body-token arena fits 65536 tokens (2 MB = 0x200000).
     63 DEFINE M1PP_MACRO_RECORD_SIZE 3001000000000000
     64 DEFINE M1PP_MACRO_BODY_START_OFF 1801000000000000
     65 DEFINE M1PP_MACRO_BODY_END_OFF 2001000000000000
     66 DEFINE M1PP_MACRO_HAS_PASTE_OFF 2801000000000000
     67 DEFINE M1PP_MACROS_CAP 00C0040000000000
     68 DEFINE M1PP_MACRO_BODY_CAP 0000200000000000
     69 DEFINE O_WRONLY_CREAT_TRUNC 4102000000000000
     70 DEFINE MODE_0644 A401000000000000
     71 DEFINE AT_FDCWD 9CFFFFFFFFFFFFFF
     72 DEFINE ZERO32 '0000000000000000000000000000000000000000000000000000000000000000'
     73 DEFINE ZERO8 '0000000000000000'
     74 DEFINE ZERO4 '00000000'
     75 
     76 DEFINE TOK_WORD 0000000000000000
     77 DEFINE TOK_STRING 0100000000000000
     78 DEFINE TOK_NEWLINE 0200000000000000
     79 DEFINE TOK_LPAREN 0300000000000000
     80 DEFINE TOK_RPAREN 0400000000000000
     81 DEFINE TOK_COMMA 0500000000000000
     82 DEFINE TOK_PASTE 0600000000000000
     83 DEFINE TOK_LBRACE 0700000000000000
     84 DEFINE TOK_RBRACE 0800000000000000
     85 
     86 ## Token record stride (kind + text_ptr + text_len + tight). Advance by this.
     87 ## Layout: +0 kind (8), +8 text_ptr (8), +16 text_len (8), +24 tight (8) = 32.
     88 ## Only byte 0 of the tight word is meaningful (0/1); upper bytes are zero.
     89 DEFINE M1PP_TOK_SIZE 2000000000000000
     90 DEFINE M1PP_TOK_TIGHT_OFF 1800000000000000
     91 
     92 ## --- Stream / expansion-pool / expression-frame sizes ------------------------
     93 ## Stream record: 40 bytes. Fields (each 8 bytes):
     94 ##   +0   start       Token*
     95 ##   +8   end         Token*   (exclusive)
     96 ##   +16  pos         Token*
     97 ##   +24  line_start  u64      (1 at entry, 0 after first non-newline emit)
     98 ##   +32  pool_mark   i64      (byte offset into expand_pool; -1 for source)
     99 DEFINE M1PP_STREAM_SIZE 2800000000000000
    100 DEFINE M1PP_STREAM_END_OFF 0800000000000000
    101 DEFINE M1PP_STREAM_POS_OFF 1000000000000000
    102 DEFINE M1PP_STREAM_LS_OFF 1800000000000000
    103 DEFINE M1PP_STREAM_MARK_OFF 2000000000000000
    104 
    105 ## Stream stack cap: 128 streams × 40 = 5120 bytes (was 16 × 40 = 640).
    106 ## Each nested macro / expression eval pushes a stream; cc.scm's macro
    107 ## chains (%ld → %p1_mem → %select → %aa64_mem → %aa64_mem_after_nonneg
    108 ## → %aa64_ldst_uimm12 → %(EXPR), plus %fn / %ifelse_* / @local label
    109 ## scopes around the call site) routinely exceed 16 deep on the
    110 ## tcc-boot2 path. Note: the runtime overflow message ("token buffer
    111 ## overflow") at line 2504 is misleadingly shared with EXPAND/TOKENS;
    112 ## hitting the cap there means *stream* stack exhaustion.
    113 DEFINE M1PP_STREAM_STACK_CAP 0014000000000000
    114 
    115 ## Expansion pool fits 524288 Token slots × 32 bytes = 16 MB (0x1000000).
    116 ## cc.scm emits %fn(name, sz, { body }), and m1pp eagerly copies the
    117 ## body argument into the pool when expanding %fn — so the entire body
    118 ## of a long function lives in the pool until %fn pops. tcc.c's
    119 ## next_nomacro1 (~5900 lines × ~13 tokens/line ≈ 77 K tokens, ~2.5 MiB)
    120 ## plus nested expansions sit comfortably under 16 MiB.
    121 DEFINE M1PP_EXPAND_CAP 0000000100000000
    122 
    123 ## ExprFrame record: 144 bytes. Fields:
    124 ##   +0   op_code  u64
    125 ##   +8   argc     u64
    126 ##   +16  args     i64[16]  (16 × 8 = 128 bytes)
    127 DEFINE M1PP_EXPR_FRAME_SIZE 9000000000000000
    128 DEFINE M1PP_EXPR_ARGC_OFF 0800000000000000
    129 DEFINE M1PP_EXPR_ARGS_OFF 1000000000000000
    130 
    131 ## Expr frame stack cap: 16 frames × 144 = 2304 bytes.
    132 DEFINE M1PP_EXPR_FRAMES_CAP 0009000000000000
    133 
    134 ## Common cap used by macro params, call args, and expression args.
    135 DEFINE M1PP_MAX_PARAMS 1000000000000000
    136 
    137 ## ExprOp codes (indexed by apply_expr_op).
    138 DEFINE EXPR_ADD 0000000000000000
    139 DEFINE EXPR_SUB 0100000000000000
    140 DEFINE EXPR_MUL 0200000000000000
    141 DEFINE EXPR_DIV 0300000000000000
    142 DEFINE EXPR_MOD 0400000000000000
    143 DEFINE EXPR_SHL 0500000000000000
    144 DEFINE EXPR_SHR 0600000000000000
    145 DEFINE EXPR_AND 0700000000000000
    146 DEFINE EXPR_OR 0800000000000000
    147 DEFINE EXPR_XOR 0900000000000000
    148 DEFINE EXPR_NOT 0A00000000000000
    149 DEFINE EXPR_EQ 0B00000000000000
    150 DEFINE EXPR_NE 0C00000000000000
    151 DEFINE EXPR_LT 0D00000000000000
    152 DEFINE EXPR_LE 0E00000000000000
    153 DEFINE EXPR_GT 0F00000000000000
    154 DEFINE EXPR_GE 1000000000000000
    155 DEFINE EXPR_STRLEN 1100000000000000
    156 DEFINE EXPR_INVALID 1200000000000000
    157 ## --- BSS layout (offsets from ELF_end) -------------------------------------
    158 ## Total BSS ~499 MB, under the 512 MB segment memsz set by
    159 ## vendor/seed/<arch>/ELF.hex2. (If a future bump pushes past 512 MB,
    160 ## raise p_memsz in vendor/seed/<arch>/ELF.hex2 to 1 GiB and bump here.)
    161 ##
    162 ## Per-region BSS slot sizes (offset diffs):
    163 ##   input_buf          32 MB    (2x INPUT_CAP, headroom for trailing NUL etc.)
    164 ##   output_buf        128 MB    (1x OUTPUT_CAP)
    165 ##   text_buf           64 MB    (1x TEXT_CAP)
    166 ##   source_tokens     256 MB    (1x TOKENS_END)
    167 ##   macros            592 KB    (2x MACROS_CAP, unchanged)
    168 ##   macro_body_tokens   2 MB
    169 ##   streams          5120 B    (128 streams × 40 B; was 16 × 40 = 640)
    170 ##   expand_pool        16 MB    (1x EXPAND_CAP)
    171 ##   expr_frames     2304 B
    172 DEFINE OFF_paste_scratch 0000000000000000
    173 DEFINE OFF_local_label_scratch 0001000000000000
    174 DEFINE OFF_df_name_scratch 8003000000000000
    175 DEFINE OFF_ebc_str_scratch 8004000000000000
    176 DEFINE OFF_arg_starts 8005000000000000
    177 DEFINE OFF_arg_ends 0006000000000000
    178 DEFINE OFF_input_buf 8006000000000000
    179 DEFINE OFF_output_buf 8006000200000000
    180 DEFINE OFF_text_buf 8006000A00000000
    181 DEFINE OFF_source_tokens 8006000E00000000
    182 DEFINE OFF_macros 8006001E00000000
    183 DEFINE OFF_macro_body_tokens 8046091E00000000
    184 DEFINE OFF_streams 8046291E00000000
    185 DEFINE OFF_expand_pool 805A291E00000000
    186 DEFINE OFF_expr_frames 805A291F00000000
    187 ## local_lookup_scratch — 256-byte working buffer used by
    188 ## expand_local_into_pool to assemble "<frame>_FRAME.<field>" before
    189 ## the macro-table linear search. Placed past expr_frames (BSS end) so
    190 ## adding it does not shift any existing OFF_*.
    191 DEFINE OFF_local_lookup_scratch 0052850000000000
    192 ## macro_body_param_idx / macro_body_is_local_label — 1 byte per body
    193 ## token slot (M1PP_MACRO_BODY_CAP / 32 = 65536 slots, so each region
    194 ## is 0x10000 bytes). Populated at %macro definition time so the
    195 ## expand_macro_tokens body loop reads cached classifications instead
    196 ## of re-running find_param + is_local_label_token per body token per
    197 ## expansion. Placed past the existing expr_frames (BSS end ≈ 0x1F296380)
    198 ## so adding them does not shift any other OFF_*.
    199 DEFINE OFF_macro_body_param_idx 8063291F00000000
    200 DEFINE OFF_macro_body_is_local_label 80632A1F00000000
    201 
    202 
    203 ## --- Runtime shell: argv, read input, call pipeline, write output, exit ------
    204 
    205 :p1_main
    206     enter_0
    207     # --- init BSS pointer slots from ELF_end via table walk ------------------
    208     # Each bss_init_tbl entry is 16 bytes:
    209     #   +0  slot ptr   (&label + 4 zero pad = 8-byte absolute address)
    210     #   +8  offset     (8-byte OFF_* constant)
    211     # For each entry: *slot_ptr = ELF_end + offset.
    212     la_t0 &ELF_end
    213     la_t1 &bss_init_tbl
    214     la_t2 &bss_init_tbl_end
    215 :bss_init_loop
    216     la_br &bss_init_done
    217     beq_t1,t2
    218     ld_a2,t1,0
    219     ld_a3,t1,8
    220     add_a3,a3,t0
    221     st_a3,a2,0
    222     addi_t1,t1,16
    223     la_br &bss_init_loop
    224     b
    225 :bss_init_done
    226     # --- end BSS init -------------------------------------------------------
    227 
    228     # a0 = argc, a1 = argv (pointer to argv[0]).
    229     # if (argc < 3) usage
    230     li_a2 %3 %0
    231     la_br &err_usage
    232     blt_a0,a2
    233 
    234     # Stash argv[1] and argv[2] before anything clobbers a1. Native argv
    235     # entries are one target word; internal M1pp records remain padded to 8.
    236     li_t2 p1wordbytes
    237     add_t1,a1,t2
    238     ld_t0,t1,0
    239     la_a2 &input_path
    240     st_t0,a2,0
    241     add_t1,t1,t2
    242     ld_t0,t1,0
    243     la_a2 &output_path
    244     st_t0,a2,0
    245 
    246     # source_end = &source_tokens   (running tail pointer)
    247     la_a0 &source_tokens_ptr
    248     ld_a0,a0,0
    249     la_a2 &source_end
    250     st_a0,a2,0
    251 
    252     # macros_end = &macros; macro_body_end = &macro_body_tokens
    253     la_a0 &macros_ptr
    254     ld_a0,a0,0
    255     la_a2 &macros_end
    256     st_a0,a2,0
    257     la_a0 &macro_body_tokens_ptr
    258     ld_a0,a0,0
    259     la_a2 &macro_body_end
    260     st_a0,a2,0
    261 
    262     # input_fd = openat(AT_FDCWD, input_path, O_RDONLY, 0)
    263     li_a0 sys_openat
    264     li_a1 AT_FDCWD
    265     la_a2 &input_path
    266     ld_a2,a2,0
    267     li_a3 %0 %0
    268     li_t0 %0 %0
    269     syscall
    270     la_br &err_open_input
    271     bltz_a0
    272     la_a1 &input_fd
    273     st_a0,a1,0
    274 
    275 :read_loop
    276     # while (input_len < INPUT_CAP)
    277     la_a0 &input_len
    278     ld_t1,a0,0
    279     li_t2 M1PP_INPUT_CAP
    280     la_br &read_done
    281     beq_t1,t2
    282 
    283     # n = read(input_fd, &input_buf[input_len], INPUT_CAP - input_len)
    284     la_a0 &input_fd
    285     ld_a1,a0,0
    286     la_a2 &input_buf_ptr
    287     ld_a2,a2,0
    288     add_a2,a2,t1
    289     sub_a3,t2,t1
    290     li_a0 sys_read
    291     syscall
    292 
    293     # if (n == 0) break;  if (n < 0) fatal
    294     la_br &read_done
    295     beqz_a0
    296     la_br &err_read
    297     bltz_a0
    298 
    299     # input_len += n
    300     la_a1 &input_len
    301     ld_a2,a1,0
    302     add_a2,a2,a0
    303     st_a2,a1,0
    304     la_br &read_loop
    305     b
    306 
    307 :read_done
    308     # if (input_len == INPUT_CAP) fatal  (no room for null terminator)
    309     la_a0 &input_len
    310     ld_t0,a0,0
    311     li_t1 M1PP_INPUT_CAP
    312     la_br &err_input_too_big
    313     beq_t0,t1
    314 
    315     # input_buf[input_len] = '\0'
    316     la_a0 &input_buf_ptr
    317     ld_a0,a0,0
    318     add_a0,a0,t0
    319     li_t1 %0 %0
    320     sb_t1,a0,0
    321 
    322     # lex_source(); process_tokens()
    323     la_br &lex_source
    324     call
    325     la_br &process_tokens
    326     call
    327 
    328     la_br &write_output
    329     b
    330 
    331 :write_output
    332     # output_fd = openat(AT_FDCWD, output_path, O_WRONLY|O_CREAT|O_TRUNC, 0644)
    333     la_a0 &output_path
    334     ld_a2,a0,0
    335     li_a0 sys_openat
    336     li_a1 AT_FDCWD
    337     li_a3 O_WRONLY_CREAT_TRUNC
    338     li_t0 MODE_0644
    339     syscall
    340     la_br &err_open_output
    341     bltz_a0
    342     la_a1 &output_fd
    343     st_a0,a1,0
    344 
    345 :write_loop
    346     # while (output_written < output_used)
    347     la_a0 &output_written
    348     ld_t0,a0,0
    349     la_a1 &output_used
    350     ld_t1,a1,0
    351     la_br &write_done
    352     beq_t0,t1
    353 
    354     # n = write(output_fd, &output_buf[output_written], output_used - output_written)
    355     la_a0 &output_fd
    356     ld_a1,a0,0
    357     la_a2 &output_buf_ptr
    358     ld_a2,a2,0
    359     add_a2,a2,t0
    360     sub_a3,t1,t0
    361     li_a0 sys_write
    362     syscall
    363 
    364     # n <= 0 is fatal (short write or error)
    365     la_br &err_write
    366     bltz_a0
    367     la_br &err_write
    368     beqz_a0
    369 
    370     # output_written += n
    371     la_a1 &output_written
    372     ld_a2,a1,0
    373     add_a2,a2,a0
    374     st_a2,a1,0
    375     la_br &write_loop
    376     b
    377 
    378 :write_done
    379     # return 0 (backend :_start stub sys_exits with a0)
    380     li_a0 %0 %0
    381     eret
    382 
    383 ## --- Helpers: text arena + token array + equality ----------------------------
    384 ## append_text appends bytes to text_buf (used for synthesized token text,
    385 ## e.g. single-char parens/commas and the paste `##`). Source-word and string
    386 ## tokens point directly into input_buf and skip this arena.
    387 
    388 ## append_text(a0=src, a1=len) -> a0=text ptr. Leaf.
    389 :append_text
    390     # a3 = text_used
    391     la_a2 &text_used
    392     ld_a3,a2,0
    393 
    394     # if (text_used + len + 1) > TEXT_CAP: fatal
    395     add_t0,a3,a1
    396     addi_t0,t0,1
    397     li_t1 M1PP_TEXT_CAP
    398     la_br &err_text_overflow
    399     blt_t1,t0
    400 
    401     # dst = &text_buf[text_used]
    402     la_t0 &text_buf_ptr
    403     ld_t0,t0,0
    404     add_t0,t0,a3
    405 
    406     # for (i = 0; i < len; i++) dst[i] = src[i]
    407     li_t1 %0 %0
    408 :append_text_loop
    409     la_br &append_text_done
    410     beq_t1,a1
    411     add_t2,a0,t1
    412     lb_t2,t2,0
    413     add_a2,t0,t1
    414     sb_t2,a2,0
    415     addi_t1,t1,1
    416     la_br &append_text_loop
    417     b
    418 :append_text_done
    419     # dst[len] = '\0'
    420     add_a2,t0,t1
    421     li_t2 %0 %0
    422     sb_t2,a2,0
    423 
    424     # text_used += len + 1
    425     la_a2 &text_used
    426     ld_a3,a2,0
    427     add_a3,a3,a1
    428     addi_a3,a3,1
    429     st_a3,a2,0
    430 
    431     # return dst
    432     mov_a0,t0
    433     ret
    434 
    435 ## push_source_token(a0=kind, a1=text_ptr, a2=text_len, a3=tight). Leaf.
    436 ## Token layout: +0 kind, +8 text_ptr, +16 text_len, +24 tight (32B total).
    437 ## tight=1 means "no whitespace before this token"; consulted only on LPAREN
    438 ## by the paren-call recognizers (%FOO(...), !(...), @(...), %(...), $(...),
    439 ## %select(...), %str(...)). All other kinds carry the bit but ignore it.
    440 :push_source_token
    441     # tok = source_end
    442     la_t2 &source_end
    443     ld_t0,t2,0
    444 
    445     # if (tok == &source_tokens[0] + TOKENS_END) fatal
    446     la_t1 &source_tokens_ptr
    447     ld_t1,t1,0
    448     li_t2 M1PP_TOKENS_END
    449     add_t1,t1,t2
    450     la_br &err_token_overflow
    451     beq_t0,t1
    452 
    453     # tok->kind = kind; tok->text_ptr = text_ptr; tok->text_len = text_len;
    454     # tok->tight = tight (8-byte stores zero the rest of each slot).
    455     st_a0,t0,0
    456     st_a1,t0,8
    457     st_a2,t0,16
    458     st_a3,t0,24
    459 
    460     # source_end = tok + 1 (advance 32 bytes)
    461     la_t2 &source_end
    462     addi_t0,t0,32
    463     st_t0,t2,0
    464     ret
    465 
    466 ## tok_eq_const(a0=token_ptr, a1=const_ptr, a2=const_len) -> a0=0/1. Leaf.
    467 ## Compares a token's text against a constant byte string.
    468 :tok_eq_const
    469     # if (tok->text_len != const_len) return 0
    470     ld_a3,a0,16
    471     la_br &tok_eq_false
    472     bne_a3,a2
    473 
    474     # src = tok->text_ptr; i = 0
    475     ld_t0,a0,8
    476     li_t1 %0 %0
    477 :tok_eq_loop
    478     # if (i == const_len) return 1
    479     la_br &tok_eq_true
    480     beq_t1,a2
    481 
    482     # if (src[i] != const_ptr[i]) return 0
    483     add_t2,t0,t1
    484     lb_t2,t2,0
    485     add_a3,a1,t1
    486     lb_a3,a3,0
    487     la_br &tok_eq_false
    488     bne_t2,a3
    489 
    490     # i++
    491     addi_t1,t1,1
    492     la_br &tok_eq_loop
    493     b
    494 :tok_eq_true
    495     li_a0 %1 %0
    496     ret
    497 :tok_eq_false
    498     li_a0 %0 %0
    499     ret
    500 
    501 ## --- Lexer -------------------------------------------------------------------
    502 ## Dispatches on the first byte at lex_ptr:
    503 ##   whitespace (sp/tab/cr/ff/vt) -> lex_skip_one
    504 ##   newline (\n)                 -> lex_newline   -> TOK_NEWLINE
    505 ##   quote (" or ')               -> lex_string    -> TOK_STRING
    506 ##   `#`                          -> lex_hash      -> TOK_PASTE on ##, else comment
    507 ##   `;`                          -> lex_comment   (drop to end of line)
    508 ##   `(` `)` `,`                  -> lex_lparen / rparen / comma
    509 ##   otherwise                    -> lex_word      -> TOK_WORD
    510 ##
    511 ## All branches loop back to lex_loop. lex_done exits once lex_ptr hits
    512 ## the terminating NUL that _start writes past the end of input_buf.
    513 
    514 ## lex_source(): fills source_tokens[] from input_buf.
    515 ## lex_saw_separator tracks whether whitespace (sp/tab/CR/etc., newline,
    516 ## or `;`/`#` line comment) precedes the next token. Each non-newline push
    517 ## passes tight = !lex_saw_separator and then sets lex_saw_separator = 0.
    518 ## Initialized to 1 at start of file so the first token is never tight.
    519 :lex_source
    520     enter_0
    521     la_a0 &input_buf_ptr
    522     ld_a0,a0,0
    523     la_a1 &lex_ptr
    524     st_a0,a1,0
    525     la_a0 &lex_saw_separator
    526     li_t0 %1 %0
    527     st_t0,a0,0
    528 :lex_loop
    529     # c = *lex_ptr; dispatch on lex_char_class[c].
    530     #   0 word, 1 skip ws, 2 newline, 3 string, 4 hash, 5 comment,
    531     #   6 '(', 7 ')', 8 ',', 9 '{', 10 '}', 11 NUL (fall through to done).
    532     la_a0 &lex_ptr
    533     ld_t0,a0,0
    534     lb_a0,t0,0
    535     la_a1 &lex_char_class
    536     add_a1,a1,a0
    537     lb_a2,a1,0
    538 
    539     la_br &lex_word
    540     beqz_a2
    541     li_a1 %1 %0
    542     la_br &lex_skip_one
    543     beq_a2,a1
    544     li_a1 %2 %0
    545     la_br &lex_newline
    546     beq_a2,a1
    547     li_a1 %3 %0
    548     la_br &lex_string
    549     beq_a2,a1
    550     li_a1 %4 %0
    551     la_br &lex_hash
    552     beq_a2,a1
    553     li_a1 %5 %0
    554     la_br &lex_comment
    555     beq_a2,a1
    556     li_a1 %6 %0
    557     la_br &lex_lparen
    558     beq_a2,a1
    559     li_a1 %7 %0
    560     la_br &lex_rparen
    561     beq_a2,a1
    562     li_a1 %8 %0
    563     la_br &lex_comma
    564     beq_a2,a1
    565     li_a1 %9 %0
    566     la_br &lex_lbrace
    567     beq_a2,a1
    568     li_a1 %10 %0
    569     la_br &lex_rbrace
    570     beq_a2,a1
    571     ## class 11 (NUL) — fall through
    572     la_br &lex_done
    573     b
    574 
    575 :lex_skip_one
    576     # whitespace separator: lex_saw_separator = 1; lex_ptr++
    577     la_a1 &lex_saw_separator
    578     li_a2 %1 %0
    579     st_a2,a1,0
    580     addi_t0,t0,1
    581     la_a0 &lex_ptr
    582     st_t0,a0,0
    583     la_br &lex_loop
    584     b
    585 
    586 :lex_newline
    587     # push_source_token(TOK_NEWLINE, lex_ptr, 1, tight=0); newline acts as a
    588     # separator for the NEXT token, so lex_saw_separator = 1 afterwards.
    589     mov_a1,t0
    590     li_a0 TOK_NEWLINE
    591     li_a2 %1 %0
    592     li_a3 %0 %0
    593     la_br &push_source_token
    594     call
    595 
    596     la_a0 &lex_saw_separator
    597     li_t0 %1 %0
    598     st_t0,a0,0
    599 
    600     # lex_ptr++
    601     la_a0 &lex_ptr
    602     ld_t0,a0,0
    603     addi_t0,t0,1
    604     st_t0,a0,0
    605     la_br &lex_loop
    606     b
    607 
    608 :lex_string
    609     # lex_start = lex_ptr; lex_quote = c; lex_ptr++
    610     la_a1 &lex_start
    611     st_t0,a1,0
    612     la_a1 &lex_quote
    613     st_a0,a1,0
    614     addi_t0,t0,1
    615 :lex_string_scan
    616     # c = *lex_ptr
    617     lb_a0,t0,0
    618     # if (c == '\0') finish (unterminated; keep what we have)
    619     la_br &lex_string_finish
    620     beqz_a0
    621     # if (c == '\\' && lex_ptr[1] != '\0') skip both bytes as a unit so
    622     # `\"` and `\\` don't accidentally terminate the string. Decoding
    623     # the escape's *meaning* (e.g. for %bytes) happens later — here we
    624     # only care about token boundaries.
    625     li_a1 %92 %0
    626     la_br &lex_string_check_backslash
    627     beq_a0,a1
    628     la_br &lex_string_no_escape
    629     b
    630 :lex_string_check_backslash
    631     addi_a1,t0,1
    632     lb_a1,a1,0
    633     la_br &lex_string_no_escape
    634     beqz_a1
    635     addi_t0,t0,2
    636     la_br &lex_string_scan
    637     b
    638 :lex_string_no_escape
    639     # if (c == quote) consume closing quote and finish
    640     la_a1 &lex_quote
    641     ld_a1,a1,0
    642     la_br &lex_string_after_quote
    643     beq_a0,a1
    644     # else lex_ptr++
    645     addi_t0,t0,1
    646     la_br &lex_string_scan
    647     b
    648 :lex_string_after_quote
    649     addi_t0,t0,1
    650 :lex_string_finish
    651     # lex_ptr = t0
    652     la_a1 &lex_ptr
    653     st_t0,a1,0
    654 
    655     # text_ptr = append_text(lex_start, lex_ptr - lex_start)
    656     la_a1 &lex_start
    657     ld_a0,a1,0
    658     sub_a1,t0,a0
    659     la_br &append_text
    660     call
    661 
    662     # push_source_token(TOK_STRING, text_ptr, lex_ptr - lex_start,
    663     #                   tight = !lex_saw_separator); then lex_saw_separator = 0.
    664     la_a1 &lex_ptr
    665     ld_t0,a1,0
    666     la_a1 &lex_start
    667     ld_t1,a1,0
    668     sub_a2,t0,t1
    669     mov_a1,a0
    670     li_a0 TOK_STRING
    671     la_a3 &lex_saw_separator
    672     ld_a3,a3,0
    673     li_t1 %1 %0
    674     sub_a3,t1,a3
    675     la_br &push_source_token
    676     call
    677     la_a0 &lex_saw_separator
    678     li_t0 %0 %0
    679     st_t0,a0,0
    680     la_br &lex_loop
    681     b
    682 
    683 :lex_hash
    684     # if (lex_ptr[1] == '#') goto lex_paste, else lex_comment
    685     addi_a1,t0,1
    686     lb_a1,a1,0
    687     li_a2 %35 %0
    688     la_br &lex_paste
    689     beq_a1,a2
    690     la_br &lex_comment
    691     b
    692 
    693 :lex_paste
    694     # text_ptr = append_text("##", 2)
    695     la_a0 &const_paste
    696     li_a1 %2 %0
    697     la_br &append_text
    698     call
    699 
    700     # push_source_token(TOK_PASTE, text_ptr, 2, tight = !lex_saw_separator).
    701     mov_a1,a0
    702     li_a0 TOK_PASTE
    703     li_a2 %2 %0
    704     la_a3 &lex_saw_separator
    705     ld_a3,a3,0
    706     li_t1 %1 %0
    707     sub_a3,t1,a3
    708     la_br &push_source_token
    709     call
    710 
    711     la_a0 &lex_saw_separator
    712     li_t0 %0 %0
    713     st_t0,a0,0
    714 
    715     # lex_ptr += 2
    716     la_a0 &lex_ptr
    717     ld_t0,a0,0
    718     addi_t0,t0,2
    719     st_t0,a0,0
    720     la_br &lex_loop
    721     b
    722 
    723 :lex_comment
    724     # skip to end of line: while (c != '\0' && c != '\n') lex_ptr++
    725     la_a0 &lex_ptr
    726     ld_t0,a0,0
    727 :lex_comment_loop
    728     lb_a0,t0,0
    729     la_br &lex_comment_done
    730     beqz_a0
    731     li_a1 %10 %0
    732     la_br &lex_comment_done
    733     beq_a0,a1
    734     addi_t0,t0,1
    735     la_br &lex_comment_loop
    736     b
    737 :lex_comment_done
    738     # `;` / `#` line comment counts as a separator for the next token.
    739     la_a0 &lex_ptr
    740     st_t0,a0,0
    741     la_a0 &lex_saw_separator
    742     li_t0 %1 %0
    743     st_t0,a0,0
    744     la_br &lex_loop
    745     b
    746 
    747 ## lex_lparen / lex_rparen / lex_comma all share the same shape:
    748 ## append the single-char constant, push a 1-byte token of the right kind,
    749 ## then fall through to lex_advance_one_then_loop to bump lex_ptr.
    750 
    751 :lex_lparen
    752     li_a0 TOK_LPAREN
    753     la_a1 &const_lparen
    754     la_br &lex_punct1
    755     b
    756 :lex_rparen
    757     li_a0 TOK_RPAREN
    758     la_a1 &const_rparen
    759     la_br &lex_punct1
    760     b
    761 :lex_comma
    762     li_a0 TOK_COMMA
    763     la_a1 &const_comma
    764     la_br &lex_punct1
    765     b
    766 :lex_lbrace
    767     li_a0 TOK_LBRACE
    768     la_a1 &const_lbrace
    769     la_br &lex_punct1
    770     b
    771 :lex_rbrace
    772     li_a0 TOK_RBRACE
    773     la_a1 &const_rbrace
    774     ## fall through into lex_punct1
    775 
    776 ## lex_punct1(a0=kind, a1=const_ptr): append 1 byte to text arena, push a
    777 ## 1-byte token of the given kind, advance lex_ptr by 1, branch back to
    778 ## lex_loop. Called by tail-branch from the single-char lex_X blocks, which
    779 ## all share lex_source's frame. Spills `kind` since append_text clobbers
    780 ## a0..a3.
    781 :lex_punct1
    782     la_t0 &lex_punct_kind
    783     st_a0,t0,0
    784     mov_a0,a1
    785     li_a1 %1 %0
    786     la_br &append_text
    787     call
    788     mov_a1,a0
    789     la_t0 &lex_punct_kind
    790     ld_a0,t0,0
    791     li_a2 %1 %0
    792     # tight = !lex_saw_separator (the load is consumed before push_source_token).
    793     la_a3 &lex_saw_separator
    794     ld_a3,a3,0
    795     li_t1 %1 %0
    796     sub_a3,t1,a3
    797     la_br &push_source_token
    798     call
    799     la_a0 &lex_saw_separator
    800     li_t0 %0 %0
    801     st_t0,a0,0
    802     ## fall through to lex_advance_one_then_loop
    803 
    804 :lex_advance_one_then_loop
    805     # lex_ptr++
    806     la_a0 &lex_ptr
    807     ld_t0,a0,0
    808     addi_t0,t0,1
    809     st_t0,a0,0
    810     la_br &lex_loop
    811     b
    812 
    813 :lex_word
    814     # lex_start = lex_ptr
    815     la_a1 &lex_start
    816     st_t0,a1,0
    817 :lex_word_scan
    818     # c = *lex_ptr; terminate the word if lex_char_class[c] is non-WORD,
    819     # but treat class 3 (string-quote `"`/`'`) as part of the word too —
    820     # quotes only start a STRING token at token start, not mid-word.
    821     # That matches M1pp.c, where the WORD scanner ignores `"`/`'` and
    822     # so `\`"hi"\`` (backtick-quote-...-quote-backtick with no spaces)
    823     # lexes as a single WORD.
    824     lb_a2,t0,0
    825     la_a1 &lex_char_class
    826     add_a1,a1,a2
    827     lb_a2,a1,0
    828     la_br &lex_word_continue
    829     beqz_a2
    830     li_a1 %3 %0
    831     la_br &lex_word_finish
    832     bne_a2,a1
    833 :lex_word_continue
    834     addi_t0,t0,1
    835     la_br &lex_word_scan
    836     b
    837 :lex_word_finish
    838     # lex_ptr = t0
    839     la_a1 &lex_ptr
    840     st_t0,a1,0
    841 
    842     # text_ptr = append_text(lex_start, lex_ptr - lex_start)
    843     la_a1 &lex_start
    844     ld_a0,a1,0
    845     sub_a1,t0,a0
    846     la_br &append_text
    847     call
    848 
    849     # push_source_token(TOK_WORD, text_ptr, lex_ptr - lex_start,
    850     #                   tight = !lex_saw_separator); lex_saw_separator = 0.
    851     la_a1 &lex_ptr
    852     ld_t0,a1,0
    853     la_a1 &lex_start
    854     ld_t1,a1,0
    855     sub_a2,t0,t1
    856     mov_a1,a0
    857     li_a0 TOK_WORD
    858     la_a3 &lex_saw_separator
    859     ld_a3,a3,0
    860     li_t1 %1 %0
    861     sub_a3,t1,a3
    862     la_br &push_source_token
    863     call
    864     la_a0 &lex_saw_separator
    865     li_t0 %0 %0
    866     st_t0,a0,0
    867     la_br &lex_loop
    868     b
    869 
    870 :lex_done
    871     eret
    872 
    873 ## --- Output: normalized token stream to output_buf ---------------------------
    874 ## emit_newline writes '\n' and clears output_need_space.
    875 ## emit_token prefixes a space when output_need_space is set, copies the
    876 ## token text, then sets output_need_space. This is how source whitespace
    877 ## gets normalized: one '\n' per TOK_NEWLINE, one ' ' between consecutive
    878 ## non-newline tokens.
    879 
    880 ## emit_newline(). Leaf.
    881 :emit_newline
    882     # if (output_used == OUTPUT_CAP) fatal
    883     la_a0 &output_used
    884     ld_t0,a0,0
    885     li_t1 M1PP_OUTPUT_CAP
    886     la_br &err_output_overflow
    887     beq_t0,t1
    888 
    889     # output_buf[output_used] = '\n'; output_used++
    890     la_a1 &output_buf_ptr
    891     ld_a1,a1,0
    892     add_a1,a1,t0
    893     li_t2 %10 %0
    894     sb_t2,a1,0
    895     addi_t0,t0,1
    896     st_t0,a0,0
    897 
    898     # output_need_space = 0
    899     la_a0 &output_need_space
    900     li_a1 %0 %0
    901     st_a1,a0,0
    902     ret
    903 
    904 ## emit_token(a0=token_ptr). Tail-calls emit_string_as_bytes for
    905 ## TOK_STRING (which has its own enter_0/eret frame), so emit_token
    906 ## itself stays leaf for the WORD path.
    907 :emit_token
    908     # brace tokens are no-ops at emit time (belt-and-braces with arg-strip)
    909     ld_t0,a0,0
    910     li_t1 TOK_LBRACE
    911     la_br &emit_token_skip
    912     beq_t0,t1
    913     li_t1 TOK_RBRACE
    914     la_br &emit_token_skip
    915     beq_t0,t1
    916     # Bare TOK_STRING decodes to raw bytes via emit_string_as_bytes.
    917     # Branch (not call): the tail call returns to emit_token's caller.
    918     li_t1 TOK_STRING
    919     la_br &emit_string_as_bytes
    920     beq_t0,t1
    921 
    922     # if (output_need_space) emit ' '  (skip the space for the first token on a line)
    923     la_a1 &output_need_space
    924     ld_t0,a1,0
    925     la_br &emit_token_copy
    926     beqz_t0
    927 
    928     la_a1 &output_used
    929     ld_t0,a1,0
    930     li_t1 M1PP_OUTPUT_CAP
    931     la_br &err_output_overflow
    932     beq_t0,t1
    933     la_a2 &output_buf_ptr
    934     ld_a2,a2,0
    935     add_a2,a2,t0
    936     li_t1 %32 %0
    937     sb_t1,a2,0
    938     addi_t0,t0,1
    939     st_t0,a1,0
    940 
    941 :emit_token_copy
    942     # src = tok->text_ptr; len = tok->text_len; i = 0
    943     ld_t0,a0,8
    944     ld_t1,a0,16
    945     li_t2 %0 %0
    946 :emit_token_loop
    947     # if (i == len) done
    948     la_br &emit_token_done
    949     beq_t2,t1
    950 
    951     # if (output_used == OUTPUT_CAP) fatal
    952     la_a1 &output_used
    953     ld_a2,a1,0
    954     li_a3 M1PP_OUTPUT_CAP
    955     la_br &err_output_overflow
    956     beq_a2,a3
    957 
    958     # output_buf[output_used++] = src[i]
    959     add_a3,t0,t2
    960     lb_a3,a3,0
    961     la_a0 &output_buf_ptr
    962     ld_a0,a0,0
    963     add_a0,a0,a2
    964     sb_a3,a0,0
    965     addi_a2,a2,1
    966     st_a2,a1,0
    967 
    968     # i++
    969     addi_t2,t2,1
    970     la_br &emit_token_loop
    971     b
    972 :emit_token_done
    973     # output_need_space = 1
    974     la_a0 &output_need_space
    975     li_a1 %1 %0
    976     st_a1,a0,0
    977     ret
    978 :emit_token_skip
    979     ret
    980 
    981 
    982 ## --- Main processor ----------------------------------------------------------
    983 ## Stream-driven loop. Pushes source_tokens as the initial stream, then drives
    984 ## the streams[] stack until it empties. Per iteration: pop the stream if
    985 ## exhausted, otherwise dispatch on the current token:
    986 ##   - line-start %macro      -> shim into define_macro via proc_pos
    987 ##   - TOK_NEWLINE            -> emit_newline, advance, set line_start = 1
    988 ##   - WORD + LPAREN follow + name in {! @ % $ %select}
    989 ##                            -> expand_builtin_call(s, tok)
    990 ##   - find_macro(tok) hit + LPAREN follow
    991 ##                            -> expand_call(s, macro)
    992 ##   - otherwise              -> emit_token, advance, clear line_start
    993 ##
    994 ## Stack frame: enter_16 reserves two 8-byte slots so we can preserve the
    995 ## current Stream* (sp+16) and the current Token* (sp+24) across calls
    996 ## (a0..a3, t0..t2 are caller-saved).
    997 
    998 ## process_tokens(): stream-driven main loop.
    999 :process_tokens
   1000     enter_16
   1001 
   1002     # push_stream_span(source_tokens, source_end, -1)
   1003     la_a0 &source_tokens_ptr
   1004     ld_a0,a0,0
   1005     la_a1 &source_end
   1006     ld_a1,a1,0
   1007     sub_a2,a2,a2
   1008     addi_a2,a2,neg1
   1009     la_br &push_stream_span
   1010     call
   1011 
   1012 ## proc_loop dispatch refactor:
   1013 ##
   1014 ## A — first-byte gate: most pass-through tokens (plain identifiers,
   1015 ##     hex literals, the synthetic WORDs emitted by !@%$ evaluation)
   1016 ##     don't begin with %, !, @, or $ and exit through proc_emit after
   1017 ##     a single byte compare. The old cascade ran 5 directive +  up to
   1018 ##     7 builtin tok_eq_const probes for *every* WORD.
   1019 ## B — second-byte dispatch: within the c0=='%' branch, a single
   1020 ##     c1-byte switch picks at most one directive/builtin to actually
   1021 ##     compare against (e.g. c1='s' selects %struct/%select/%str only).
   1022 ##     A user macro %FOO with c1 outside {m,s,e,f,b,l,r} skips every
   1023 ##     tok_eq_const and goes straight to find_macro.
   1024 :proc_loop
   1025     # s = current_stream();  if (s == 0) done
   1026     la_br &current_stream
   1027     call
   1028     la_br &proc_done
   1029     beqz_a0
   1030     st_a0,sp,0
   1031 
   1032     # if (s->pos == s->end) pop and continue
   1033     ld_t0,a0,16
   1034     ld_t1,a0,8
   1035     la_br &proc_pop_continue
   1036     beq_t0,t1
   1037 
   1038     # tok = s->pos
   1039     st_t0,sp,8
   1040 
   1041     # ---- TOK_NEWLINE fast path ----
   1042     ld_a1,t0,0
   1043     li_a2 TOK_NEWLINE
   1044     la_br &proc_handle_newline
   1045     beq_a1,a2
   1046 
   1047     # Non-WORD tokens (LPAREN, RPAREN, COMMA, LBRACE, RBRACE, STRING,
   1048     # PASTE) skip the whole dispatch and emit literally.
   1049     li_a2 TOK_WORD
   1050     la_br &proc_emit
   1051     bne_a1,a2
   1052 
   1053     # tok->text.len, ptr — needed for the byte gate.
   1054     ld_t1,t0,16
   1055     la_br &proc_emit
   1056     beqz_t1
   1057     ld_t2,t0,8
   1058     lb_a3,t2,0
   1059 
   1060     # has_paren = (tok+1 < s->end && (tok+1)->kind == TOK_LPAREN
   1061     #              && (tok+1)->tight). Stash to proc_has_paren.
   1062     li_a0 %0 %0
   1063     la_a1 &proc_has_paren
   1064     st_a0,a1,0
   1065     addi_a2,t0,32
   1066     ld_a1,sp,0
   1067     ld_a1,a1,8
   1068     la_br &proc_byte_gate
   1069     blt_a1,a2
   1070     la_br &proc_byte_gate
   1071     beq_a2,a1
   1072     ld_a0,a2,0
   1073     li_a1 TOK_LPAREN
   1074     la_br &proc_byte_gate
   1075     bne_a0,a1
   1076     ld_a0,a2,24
   1077     la_br &proc_byte_gate
   1078     beqz_a0
   1079     li_a0 %1 %0
   1080     la_a1 &proc_has_paren
   1081     st_a0,a1,0
   1082 
   1083 :proc_byte_gate
   1084     # c0 == '%' (37) -> percent branch
   1085     li_a0 %37 %0
   1086     la_br &proc_c0_pct
   1087     beq_a3,a0
   1088     # Arith builtins ! @ $ require len == 1 + has_paren. All other
   1089     # first-byte values fall through to proc_emit.
   1090     li_a0 %1 %0
   1091     la_br &proc_emit
   1092     bne_t1,a0
   1093     la_a1 &proc_has_paren
   1094     ld_a1,a1,0
   1095     la_br &proc_emit
   1096     beqz_a1
   1097     li_a0 %33 %0
   1098     la_br &proc_do_builtin
   1099     beq_a3,a0
   1100     li_a0 %64 %0
   1101     la_br &proc_do_builtin
   1102     beq_a3,a0
   1103     li_a0 %36 %0
   1104     la_br &proc_do_builtin
   1105     beq_a3,a0
   1106     la_br &proc_emit
   1107     b
   1108 
   1109 :proc_c0_pct
   1110     # c0 == '%'. Bare '%' (len == 1) + tight-paren -> arith builtin.
   1111     li_a0 %1 %0
   1112     la_br &proc_c0_pct_one
   1113     beq_t1,a0
   1114     # len >= 2: dispatch on c1 (text[1]).
   1115     lb_a3,t2,1
   1116 
   1117     li_a0 %109 %0
   1118     la_br &proc_c1_m
   1119     beq_a3,a0
   1120     li_a0 %115 %0
   1121     la_br &proc_c1_s
   1122     beq_a3,a0
   1123     li_a0 %101 %0
   1124     la_br &proc_c1_e
   1125     beq_a3,a0
   1126     li_a0 %102 %0
   1127     la_br &proc_c1_f
   1128     beq_a3,a0
   1129     li_a0 %98 %0
   1130     la_br &proc_c1_b
   1131     beq_a3,a0
   1132     li_a0 %108 %0
   1133     la_br &proc_c1_l
   1134     beq_a3,a0
   1135     li_a0 %114 %0
   1136     la_br &proc_c1_r
   1137     beq_a3,a0
   1138     # No directive/builtin candidate — try user macro.
   1139     la_br &proc_check_macro
   1140     b
   1141 
   1142 :proc_c0_pct_one
   1143     la_a1 &proc_has_paren
   1144     ld_a1,a1,0
   1145     la_br &proc_emit
   1146     beqz_a1
   1147     la_br &proc_do_builtin
   1148     b
   1149 
   1150 :proc_c1_m
   1151     # %macro
   1152     ld_a0,sp,8
   1153     la_a1 &const_macro
   1154     li_a2 %6 %0
   1155     la_br &tok_eq_const
   1156     call
   1157     la_br &proc_check_macro
   1158     beqz_a0
   1159     ld_a0,sp,0
   1160     ld_a1,sp,8
   1161     la_br &proc_save_pos_and_ls
   1162     call
   1163     la_br &define_macro
   1164     call
   1165     la_br &proc_restore_and_loop
   1166     b
   1167 
   1168 :proc_c1_s
   1169     # %struct (no paren); %select / %str (paren).
   1170     ld_a0,sp,8
   1171     la_a1 &const_struct
   1172     li_a2 %7 %0
   1173     la_br &tok_eq_const
   1174     call
   1175     la_br &proc_c1_s_not_struct
   1176     beqz_a0
   1177     ld_a0,sp,0
   1178     ld_a1,sp,8
   1179     la_br &proc_save_pos_and_ls
   1180     call
   1181     li_a0 %8 %0
   1182     la_a1 &const_size
   1183     li_a2 %4 %0
   1184     la_br &define_fielded
   1185     call
   1186     la_br &proc_restore_and_loop
   1187     b
   1188 :proc_c1_s_not_struct
   1189     la_a1 &proc_has_paren
   1190     ld_a1,a1,0
   1191     la_br &proc_check_macro
   1192     beqz_a1
   1193     ld_a0,sp,8
   1194     la_a1 &const_select
   1195     li_a2 %7 %0
   1196     la_br &tok_eq_const
   1197     call
   1198     la_br &proc_c1_s_not_select
   1199     beqz_a0
   1200     la_br &proc_do_builtin
   1201     b
   1202 :proc_c1_s_not_select
   1203     ld_a0,sp,8
   1204     la_a1 &const_str
   1205     li_a2 %4 %0
   1206     la_br &tok_eq_const
   1207     call
   1208     la_br &proc_check_macro
   1209     beqz_a0
   1210     la_br &proc_do_builtin
   1211     b
   1212 
   1213 :proc_c1_e
   1214     # %enum (no paren); %endframe (no paren).
   1215     ld_a0,sp,8
   1216     la_a1 &const_enum
   1217     li_a2 %5 %0
   1218     la_br &tok_eq_const
   1219     call
   1220     la_br &proc_c1_e_not_enum
   1221     beqz_a0
   1222     ld_a0,sp,0
   1223     ld_a1,sp,8
   1224     la_br &proc_save_pos_and_ls
   1225     call
   1226     li_a0 %1 %0
   1227     la_a1 &const_count
   1228     li_a2 %5 %0
   1229     la_br &define_fielded
   1230     call
   1231     la_br &proc_restore_and_loop
   1232     b
   1233 :proc_c1_e_not_enum
   1234     ld_a0,sp,8
   1235     la_a1 &const_endframe
   1236     li_a2 %9 %0
   1237     la_br &tok_eq_const
   1238     call
   1239     la_br &proc_check_macro
   1240     beqz_a0
   1241     ld_a0,sp,0
   1242     ld_a1,sp,8
   1243     la_br &proc_save_pos_and_ls
   1244     call
   1245     ld_a0,sp,0
   1246     ld_a0,a0,8
   1247     la_br &pop_frame
   1248     call
   1249     la_br &proc_restore_and_loop
   1250     b
   1251 
   1252 :proc_c1_f
   1253     # %frame (no paren).
   1254     ld_a0,sp,8
   1255     la_a1 &const_frame
   1256     li_a2 %6 %0
   1257     la_br &tok_eq_const
   1258     call
   1259     la_br &proc_check_macro
   1260     beqz_a0
   1261     ld_a0,sp,0
   1262     ld_a1,sp,8
   1263     la_br &proc_save_pos_and_ls
   1264     call
   1265     ld_a0,sp,0
   1266     ld_a0,a0,8
   1267     la_br &push_frame
   1268     call
   1269     la_br &proc_restore_and_loop
   1270     b
   1271 
   1272 :proc_c1_b
   1273     # %bytes (paren).
   1274     la_a1 &proc_has_paren
   1275     ld_a1,a1,0
   1276     la_br &proc_check_macro
   1277     beqz_a1
   1278     ld_a0,sp,8
   1279     la_a1 &const_bytes
   1280     li_a2 %6 %0
   1281     la_br &tok_eq_const
   1282     call
   1283     la_br &proc_check_macro
   1284     beqz_a0
   1285     la_br &proc_do_builtin
   1286     b
   1287 
   1288 :proc_c1_l
   1289     # %local (paren).
   1290     la_a1 &proc_has_paren
   1291     ld_a1,a1,0
   1292     la_br &proc_check_macro
   1293     beqz_a1
   1294     ld_a0,sp,8
   1295     la_a1 &const_local
   1296     li_a2 %6 %0
   1297     la_br &tok_eq_const
   1298     call
   1299     la_br &proc_check_macro
   1300     beqz_a0
   1301     la_br &proc_do_builtin
   1302     b
   1303 
   1304 :proc_c1_r
   1305     # %reset-output BASE (no paren). Discard everything emitted by the
   1306     # macro/runtime prelude and seed a disjoint macro-hygiene id range.
   1307     ld_a0,sp,8
   1308     la_a1 &const_reset_output
   1309     li_a2 %13 %0
   1310     la_br &tok_eq_const
   1311     call
   1312     la_br &proc_check_macro
   1313     beqz_a0
   1314     ld_a0,sp,0
   1315     ld_a1,sp,8
   1316     la_br &proc_save_pos_and_ls
   1317     call
   1318     ld_a0,sp,0
   1319     ld_a0,a0,8
   1320     la_br &reset_output
   1321     call
   1322     la_br &proc_restore_and_loop
   1323     b
   1324 
   1325 :proc_handle_newline
   1326     ld_a0,sp,0
   1327     ld_t0,sp,8
   1328     addi_t0,t0,32
   1329     st_t0,a0,16
   1330     li_t1 %1 %0
   1331     st_t1,a0,24
   1332     la_br &emit_newline
   1333     call
   1334     la_br &proc_loop
   1335     b
   1336 
   1337 :proc_do_builtin
   1338     # expand_builtin_call(s, tok)
   1339     ld_a0,sp,0
   1340     ld_a1,sp,8
   1341     la_br &expand_builtin_call
   1342     call
   1343     la_br &proc_loop
   1344     b
   1345 
   1346 :proc_check_macro
   1347     # macro = find_macro(tok); if non-zero AND
   1348     #   ((tok+1 < s->end AND (tok+1)->kind == TOK_LPAREN) OR macro->param_count == 0)
   1349     # then expand_call. Paren-less form is reserved for 0-arg macros.
   1350     ld_a0,sp,8
   1351     la_br &find_macro
   1352     call
   1353     la_br &proc_emit
   1354     beqz_a0
   1355     mov_t2,a0
   1356     ld_a0,sp,0
   1357     ld_t0,sp,8
   1358     addi_t1,t0,32
   1359     ld_a1,a0,8
   1360     la_br &proc_macro_has_next
   1361     blt_t1,a1
   1362     la_br &proc_macro_zero_arg
   1363     b
   1364 :proc_macro_has_next
   1365     ld_a1,t1,0
   1366     li_a2 TOK_LPAREN
   1367     la_br &proc_macro_zero_arg
   1368     bne_a1,a2
   1369     # require (tok+1)->tight — `%FOO ( ... )` with whitespace is the
   1370     # paren-less form (zero-arg only) followed by a literal `(`.
   1371     ld_a1,t1,24
   1372     la_br &proc_macro_zero_arg
   1373     beqz_a1
   1374     ld_a0,sp,0
   1375     mov_a1,t2
   1376     la_br &expand_call
   1377     call
   1378     la_br &proc_loop
   1379     b
   1380 :proc_macro_zero_arg
   1381     # No trailing LPAREN (or LPAREN not tight). Expand only if param_count == 0.
   1382     ld_t0,t2,16
   1383     la_br &proc_emit
   1384     bnez_t0
   1385     ld_a0,sp,0
   1386     mov_a1,t2
   1387     la_br &expand_call
   1388     call
   1389     la_br &proc_loop
   1390     b
   1391 
   1392 :proc_emit
   1393     # emit_token(tok); s->pos += 24; s->line_start = 0
   1394     ld_a0,sp,8
   1395     la_br &emit_token
   1396     call
   1397     ld_a0,sp,0
   1398     ld_t0,a0,16
   1399     addi_t0,t0,32
   1400     st_t0,a0,16
   1401     li_t1 %0 %0
   1402     st_t1,a0,24
   1403     la_br &proc_loop
   1404     b
   1405 
   1406 :proc_pop_continue
   1407     la_br &pop_stream
   1408     call
   1409     la_br &proc_loop
   1410     b
   1411 
   1412 :proc_done
   1413     # Every %frame must be matched by an %endframe before EOF.
   1414     la_a0 &frame_active
   1415     ld_t0,a0,0
   1416     la_br &err_frame_not_closed
   1417     bnez_t0
   1418     eret
   1419 
   1420 ## proc_save_pos_and_ls(a0=s, a1=tok): publish the directive's position into
   1421 ## proc_pos / proc_line_start so the directive handler can drive against the
   1422 ## source stream. Leaf — preserves the caller's other state.
   1423 :proc_save_pos_and_ls
   1424     la_t0 &proc_pos
   1425     st_a1,t0,0
   1426     ld_t1,a0,24
   1427     la_t0 &proc_line_start
   1428     st_t1,t0,0
   1429     ret
   1430 
   1431 ## proc_restore_and_loop: reached only via `b` (sp must be intact). Reads
   1432 ## sp,0 = s; copies proc_pos into s->pos, sets s->line_start=1, jumps to
   1433 ## proc_loop. Tail of every directive shim above.
   1434 :proc_restore_and_loop
   1435     ld_a0,sp,0
   1436     la_a1 &proc_pos
   1437     ld_t0,a1,0
   1438     st_t0,a0,16
   1439     li_t1 %1 %0
   1440     st_t1,a0,24
   1441     la_br &proc_loop
   1442     b
   1443 
   1444 ## --- %frame / %endframe handlers --------------------------------------------
   1445 ## Single-slot frame state used by %local. push_frame(a0=stream_end) parses
   1446 ## `%frame NAME`, stashes name's TextSpan in current_frame_ptr/_len, and
   1447 ## sets frame_active = 1. pop_frame(a0=stream_end) clears frame_active.
   1448 ## Frames do not nest — a second push without an intervening pop is fatal.
   1449 
   1450 :push_frame
   1451     enter_0
   1452 
   1453     # proc_pos += 32 (skip past the `%frame` token).
   1454     la_t0 &proc_pos
   1455     ld_t1,t0,0
   1456     addi_t1,t1,32
   1457     st_t1,t0,0
   1458 
   1459     # Skip newlines between `%frame` and NAME.
   1460     la_a1 &pf_stream_end
   1461     st_a0,a1,0
   1462     la_br &proc_skip_newlines
   1463     call
   1464     la_a1 &pf_stream_end
   1465     ld_a0,a1,0
   1466     la_t0 &proc_pos
   1467     ld_t1,t0,0
   1468 
   1469     # Require a WORD name token within the stream.
   1470     la_br &err_bad_frame_header
   1471     beq_t1,a0
   1472     ld_t2,t1,0
   1473     la_br &err_bad_frame_header
   1474     bnez_t2
   1475 
   1476     # !frame_active (cannot nest)
   1477     la_a1 &frame_active
   1478     ld_a2,a1,0
   1479     la_br &err_frame_already_active
   1480     bnez_a2
   1481 
   1482     # current_frame_ptr = name.text_ptr; current_frame_len = name.text_len
   1483     la_a3 &current_frame_ptr
   1484     ld_t2,t1,8
   1485     st_t2,a3,0
   1486     la_a3 &current_frame_len
   1487     ld_t2,t1,16
   1488     st_t2,a3,0
   1489 
   1490     # frame_active = 1
   1491     li_a2 %1 %0
   1492     st_a2,a1,0
   1493 
   1494     # proc_pos += 32 (past the name).
   1495     la_t0 &proc_pos
   1496     ld_t1,t0,0
   1497     addi_t1,t1,32
   1498     st_t1,t0,0
   1499 
   1500     # Newlines between `%frame NAME` and the body content are insignificant.
   1501     la_br &proc_skip_newlines
   1502     call
   1503     eret
   1504 
   1505 ## pop_frame(a0 = stream_end): consume `%endframe` followed by a strict
   1506 ## TOK_NEWLINE. Fatal if no frame is active.
   1507 :pop_frame
   1508     enter_0
   1509 
   1510     # frame_active?
   1511     la_a1 &frame_active
   1512     ld_a2,a1,0
   1513     la_br &err_frame_underflow
   1514     beqz_a2
   1515     li_a2 %0 %0
   1516     st_a2,a1,0
   1517 
   1518     # proc_pos += 32 (past the `%endframe` token).
   1519     la_t0 &proc_pos
   1520     ld_t1,t0,0
   1521     addi_t1,t1,32
   1522     st_t1,t0,0
   1523 
   1524     # Strict: the token immediately after `%endframe` must be TOK_NEWLINE.
   1525     la_br &err_bad_frame_header
   1526     beq_t1,a0
   1527     ld_t2,t1,0
   1528     li_t0 TOK_NEWLINE
   1529     la_br &err_bad_frame_header
   1530     bne_t2,t0
   1531     # Consume the trailing newline only when %endframe sat at line-start;
   1532     # mid-line %endframe leaves the newline so it can be emitted.
   1533     la_t0 &proc_line_start
   1534     ld_a1,t0,0
   1535     la_br &pop_frame_done
   1536     beqz_a1
   1537     addi_t1,t1,32
   1538     la_t0 &proc_pos
   1539     st_t1,t0,0
   1540 :pop_frame_done
   1541     eret
   1542 
   1543 ## reset_output(a0 = stream_end): consume a line-start `%reset-output BASE`,
   1544 ## clear the buffered output, and install BASE as next_expansion_id. The next
   1545 ## macro expansion receives BASE+1. This lets partitioned builds replay a
   1546 ## shared macro prelude without duplicating its runtime output or hygiene ids.
   1547 :reset_output
   1548     enter_16
   1549     st_a0,sp,0
   1550 
   1551     # Directive is valid only at line start.
   1552     la_t0 &proc_line_start
   1553     ld_t1,t0,0
   1554     la_br &err_bad_directive
   1555     beqz_t1
   1556 
   1557     # Advance to BASE and require a WORD token within the stream.
   1558     la_t0 &proc_pos
   1559     ld_t1,t0,0
   1560     addi_t1,t1,32
   1561     st_t1,t0,0
   1562     ld_a0,sp,0
   1563     la_br &err_bad_directive
   1564     beq_t1,a0
   1565     ld_t2,t1,0
   1566     la_br &err_bad_directive
   1567     bnez_t2
   1568 
   1569     # parse_int_token(BASE), accepting 0..INT_MAX-1.
   1570     mov_a0,t1
   1571     la_br &parse_int_token
   1572     call
   1573     la_br &err_bad_directive
   1574     bltz_a0
   1575     li_t0 %2147483646 %0
   1576     la_br &err_bad_directive
   1577     blt_t0,a0
   1578     la_t0 &next_expansion_id
   1579     st_a0,t0,0
   1580 
   1581     # Require and consume the newline immediately following BASE.
   1582     la_t0 &proc_pos
   1583     ld_t1,t0,0
   1584     addi_t1,t1,32
   1585     st_t1,t0,0
   1586     ld_a0,sp,0
   1587     la_br &err_bad_directive
   1588     beq_t1,a0
   1589     ld_t2,t1,0
   1590     li_t0 TOK_NEWLINE
   1591     la_br &err_bad_directive
   1592     bne_t2,t0
   1593     addi_t1,t1,32
   1594     la_t0 &proc_pos
   1595     st_t1,t0,0
   1596 
   1597     # Drop the replayed prelude's output and reset token spacing.
   1598     li_t0 %0 %0
   1599     la_t1 &output_used
   1600     st_t0,t1,0
   1601     la_t1 &output_need_space
   1602     st_t0,t1,0
   1603     eret
   1604 
   1605 ## --- %macro storage: parse header + body into macros[] / macro_body_tokens --
   1606 ## Called at proc_pos == line-start `%macro`. Leaves proc_pos past the %endm
   1607 ## line with proc_line_start = 1. Uses BSS scratch (def_m_ptr, def_param_ptr,
   1608 ## def_body_line_start) since P1 enter/eret does not save s* registers.
   1609 ##
   1610 ## Macro record layout (296 bytes, see M1PP_MACRO_RECORD_SIZE):
   1611 ##   +0   name.ptr        (8)
   1612 ##   +8   name.len        (8)
   1613 ##   +16  param_count     (8)
   1614 ##   +24  params[16].ptr/.len  (16 * 16 = 256)
   1615 ##   +280 body_start      (8)  -> *Token into macro_body_tokens[]
   1616 ##   +288 body_end        (8)  -> exclusive end
   1617 
   1618 ## define_macro(): consume `%macro NAME(params...)\n ... %endm\n`.
   1619 :define_macro
   1620     enter_0
   1621 
   1622     # macros_end bounds check: if (macros_end == &macros + MACROS_CAP) fatal
   1623     la_a0 &macros_end
   1624     ld_t0,a0,0
   1625     la_a1 &macros_ptr
   1626     ld_a1,a1,0
   1627     li_a2 M1PP_MACROS_CAP
   1628     add_a1,a1,a2
   1629     la_br &err_too_many_macros
   1630     beq_t0,a1
   1631 
   1632     # def_m_ptr = macros_end   (Macro *m = &macros[macro_count])
   1633     la_a1 &def_m_ptr
   1634     st_t0,a1,0
   1635 
   1636     # advance past the %macro token itself
   1637     la_a0 &proc_pos
   1638     ld_t0,a0,0
   1639     addi_t0,t0,32
   1640     st_t0,a0,0
   1641 
   1642     # Header is whitespace-insensitive: newlines between the keyword and
   1643     # any header element (NAME, '(', params, ',', ')') are skipped.
   1644     la_br &proc_skip_newlines
   1645     call
   1646 
   1647     # ---- header: name (WORD) ----
   1648     la_a0 &proc_pos
   1649     ld_t0,a0,0
   1650     la_a1 &source_end
   1651     ld_t1,a1,0
   1652     la_br &err_bad_macro_header
   1653     beq_t0,t1
   1654     ld_a1,t0,0
   1655     li_a2 TOK_WORD
   1656     la_br &err_bad_macro_header
   1657     bne_a1,a2
   1658 
   1659     # m->name.ptr = tok->text_ptr; m->name.len = tok->text_len
   1660     ld_a2,t0,8
   1661     ld_a3,t0,16
   1662     la_a0 &def_m_ptr
   1663     ld_t2,a0,0
   1664     st_a2,t2,0
   1665     st_a3,t2,8
   1666 
   1667     # m->param_count = 0; def_param_ptr = m + 24 (first TextSpan slot in macro)
   1668     li_a0 %0 %0
   1669     st_a0,t2,16
   1670     addi_t2,t2,24
   1671     la_a0 &def_param_ptr
   1672     st_t2,a0,0
   1673 
   1674     # advance past name
   1675     addi_t0,t0,32
   1676     la_a0 &proc_pos
   1677     st_t0,a0,0
   1678 
   1679     la_br &proc_skip_newlines
   1680     call
   1681 
   1682     # ---- header: LPAREN ----
   1683     la_a0 &proc_pos
   1684     ld_t0,a0,0
   1685     la_a1 &source_end
   1686     ld_t1,a1,0
   1687     la_br &err_bad_macro_header
   1688     beq_t0,t1
   1689     ld_a1,t0,0
   1690     li_a2 TOK_LPAREN
   1691     la_br &err_bad_macro_header
   1692     bne_a1,a2
   1693 
   1694     # advance past '('
   1695     addi_t0,t0,32
   1696     la_a0 &proc_pos
   1697     st_t0,a0,0
   1698 
   1699     la_br &proc_skip_newlines
   1700     call
   1701 
   1702     # ---- header: optional param list ----
   1703     # if at end -> fall through to RPAREN check (which will fail)
   1704     # if next is RPAREN -> skip the param loop
   1705     # else enter param loop
   1706     la_a0 &proc_pos
   1707     ld_t0,a0,0
   1708     la_a1 &source_end
   1709     ld_t1,a1,0
   1710     la_br &def_header_close
   1711     beq_t0,t1
   1712     ld_a1,t0,0
   1713     li_a2 TOK_RPAREN
   1714     la_br &def_header_close
   1715     beq_a1,a2
   1716 
   1717 :def_param_loop
   1718     # reject > 16 params: if (15 < param_count) fail   (param_count capped at 16)
   1719     la_a0 &def_m_ptr
   1720     ld_t2,a0,0
   1721     ld_a1,t2,16
   1722     li_a2 %15 %0
   1723     la_br &err_bad_macro_header
   1724     blt_a2,a1
   1725 
   1726     # tok must be in range and WORD
   1727     la_a0 &proc_pos
   1728     ld_t0,a0,0
   1729     la_a1 &source_end
   1730     ld_t1,a1,0
   1731     la_br &err_bad_macro_header
   1732     beq_t0,t1
   1733     ld_a1,t0,0
   1734     li_a2 TOK_WORD
   1735     la_br &err_bad_macro_header
   1736     bne_a1,a2
   1737 
   1738     # *def_param_ptr = (tok.text_ptr, tok.text_len); def_param_ptr += 16
   1739     ld_a2,t0,8
   1740     ld_a3,t0,16
   1741     la_a0 &def_param_ptr
   1742     ld_t1,a0,0
   1743     st_a2,t1,0
   1744     st_a3,t1,8
   1745     addi_t1,t1,16
   1746     st_t1,a0,0
   1747 
   1748     # m->param_count++
   1749     la_a0 &def_m_ptr
   1750     ld_t2,a0,0
   1751     ld_a1,t2,16
   1752     addi_a1,a1,1
   1753     st_a1,t2,16
   1754 
   1755     # advance past the param word
   1756     addi_t0,t0,32
   1757     la_a0 &proc_pos
   1758     st_t0,a0,0
   1759 
   1760     # Skip newlines between a param and the following ',' or ')'.
   1761     la_br &proc_skip_newlines
   1762     call
   1763 
   1764     # if next is COMMA, consume and loop; else break
   1765     la_a0 &proc_pos
   1766     ld_t0,a0,0
   1767     la_a1 &source_end
   1768     ld_t1,a1,0
   1769     la_br &def_header_close
   1770     beq_t0,t1
   1771     ld_a1,t0,0
   1772     li_a2 TOK_COMMA
   1773     la_br &def_header_close
   1774     bne_a1,a2
   1775     addi_t0,t0,32
   1776     la_a0 &proc_pos
   1777     st_t0,a0,0
   1778     # Skip newlines after ',' so the next param can be on a new line.
   1779     la_br &proc_skip_newlines
   1780     call
   1781     la_br &def_param_loop
   1782     b
   1783 
   1784 :def_header_close
   1785     # ---- header: RPAREN ----
   1786     la_a0 &proc_pos
   1787     ld_t0,a0,0
   1788     la_a1 &source_end
   1789     ld_t1,a1,0
   1790     la_br &err_bad_macro_header
   1791     beq_t0,t1
   1792     ld_a1,t0,0
   1793     li_a2 TOK_RPAREN
   1794     la_br &err_bad_macro_header
   1795     bne_a1,a2
   1796 
   1797     addi_t0,t0,32
   1798     la_a0 &proc_pos
   1799     st_t0,a0,0
   1800 
   1801     # ---- header self-terminates at ')'. Newlines between the header line
   1802     # ----  and the body content are insignificant — skip them.
   1803     la_br &proc_skip_newlines
   1804     call
   1805 
   1806     # ---- body: m->body_start = macro_body_end ----
   1807     la_a1 &macro_body_end
   1808     ld_t2,a1,0
   1809     la_a0 &def_m_ptr
   1810     ld_t1,a0,0
   1811     li_a0 M1PP_MACRO_BODY_START_OFF
   1812     add_a0,t1,a0
   1813     st_t2,a0,0
   1814 
   1815 :def_body_loop
   1816     # if proc_pos == source_end: unterminated %macro
   1817     la_a0 &proc_pos
   1818     ld_t0,a0,0
   1819     la_a1 &source_end
   1820     ld_t1,a1,0
   1821     la_br &err_unterminated_macro
   1822     beq_t0,t1
   1823 
   1824     # %endm is recognized anywhere in the body (no line-start gating). If
   1825     # (tok.kind == TOK_WORD) and (tok eq "%endm"), break; else copy token.
   1826     ld_a1,t0,0
   1827     li_a2 TOK_WORD
   1828     la_br &def_body_copy
   1829     bne_a1,a2
   1830 
   1831     mov_a0,t0
   1832     la_a1 &const_endm
   1833     li_a2 %5 %0
   1834     la_br &tok_eq_const
   1835     call
   1836     la_br &def_body_copy
   1837     beqz_a0
   1838 
   1839     # matched %endm: advance past it and require a TOK_NEWLINE next.
   1840     la_br &def_endm_after
   1841     b
   1842 
   1843 :def_body_copy
   1844     # bounds: if (macro_body_end - macro_body_tokens + 32 > MACRO_BODY_CAP) fail
   1845     la_a0 &macro_body_end
   1846     ld_t1,a0,0
   1847     la_a2 &macro_body_tokens_ptr
   1848     ld_a2,a2,0
   1849     sub_a3,t1,a2
   1850     addi_a3,a3,32
   1851     li_t2 M1PP_MACRO_BODY_CAP
   1852     la_br &err_macro_body_overflow
   1853     blt_t2,a3
   1854 
   1855     # === C: cache classification for this body token, so expand_macro_tokens
   1856     # === doesn't re-run find_param + is_local_label_token per body token per
   1857     # === expansion. Computed once at definition; written to byte-stride
   1858     # === parallel arrays indexed by (slot - macro_body_tokens_ptr) / 32.
   1859     # offset = (t1 - macro_body_tokens_ptr) / 32   (t1 still = macro_body_end)
   1860     la_a0 &macro_body_tokens_ptr
   1861     ld_a0,a0,0
   1862     sub_a0,t1,a0
   1863     shri_a0,a0,5
   1864     la_a1 &def_body_meta_idx
   1865     st_a0,a1,0
   1866 
   1867     # macro_body_param_idx[idx] = find_param(def_m_ptr, proc_pos)
   1868     la_a0 &def_m_ptr
   1869     ld_a0,a0,0
   1870     la_a1 &proc_pos
   1871     ld_a1,a1,0
   1872     la_br &find_param
   1873     call
   1874     la_a1 &def_body_meta_idx
   1875     ld_a1,a1,0
   1876     la_a2 &macro_body_param_idx_ptr
   1877     ld_a2,a2,0
   1878     add_a2,a2,a1
   1879     sb_a0,a2,0
   1880 
   1881     # macro_body_is_local_label[idx] = (kind==WORD && len>=3 && p[0] in ':&' && p[1]=='@')
   1882     # Inlined — there's no separate is_local_label_token function in P1; the
   1883     # full predicate is replicated here in def-time, replacing the per-expansion
   1884     # emt_check_local_label sequence.
   1885     la_a0 &proc_pos
   1886     ld_t0,a0,0
   1887     li_a3 %0 %0
   1888     ld_a1,t0,0
   1889     la_br &def_body_copy_ill_store
   1890     bnez_a1
   1891     ld_a1,t0,16
   1892     li_a2 %3 %0
   1893     la_br &def_body_copy_ill_store
   1894     blt_a1,a2
   1895     ld_a2,t0,8
   1896     lb_a1,a2,0
   1897     li_a0 %58 %0
   1898     la_br &def_body_copy_ill_at
   1899     beq_a1,a0
   1900     li_a0 %38 %0
   1901     la_br &def_body_copy_ill_store
   1902     bne_a1,a0
   1903 :def_body_copy_ill_at
   1904     lb_a1,a2,1
   1905     li_a0 %64 %0
   1906     la_br &def_body_copy_ill_store
   1907     bne_a1,a0
   1908     li_a3 %1 %0
   1909 :def_body_copy_ill_store
   1910     la_a1 &def_body_meta_idx
   1911     ld_a1,a1,0
   1912     la_a2 &macro_body_is_local_label_ptr
   1913     ld_a2,a2,0
   1914     add_a2,a2,a1
   1915     sb_a3,a2,0
   1916     # === end C ===
   1917 
   1918     # If the body token is TOK_PASTE, set m->has_paste = 1. expand_macro_tokens
   1919     # uses this (combined with args_have_paste) to skip paste_pool_range when
   1920     # neither the body nor the call's args contribute a `##`. Defaulted to 0
   1921     # by BSS init when macros_end advanced into this slot.
   1922     la_a0 &proc_pos
   1923     ld_t0,a0,0
   1924     ld_a1,t0,0
   1925     li_a2 TOK_PASTE
   1926     la_br &def_body_copy_after_paste_chk
   1927     bne_a1,a2
   1928     la_a0 &def_m_ptr
   1929     ld_a2,a0,0
   1930     li_a3 M1PP_MACRO_HAS_PASTE_OFF
   1931     add_a2,a2,a3
   1932     li_a3 %1 %0
   1933     st_a3,a2,0
   1934 
   1935 :def_body_copy_after_paste_chk
   1936     # Reload t1 = macro_body_end — clobbered by find_param above.
   1937     la_a0 &macro_body_end
   1938     ld_t1,a0,0
   1939 
   1940     # copy 32 bytes from *proc_pos to *macro_body_end (preserves tight at +24)
   1941     la_a0 &proc_pos
   1942     ld_t0,a0,0
   1943     ld_a1,t0,0
   1944     st_a1,t1,0
   1945     ld_a1,t0,8
   1946     st_a1,t1,8
   1947     ld_a1,t0,16
   1948     st_a1,t1,16
   1949     ld_a1,t0,24
   1950     st_a1,t1,24
   1951 
   1952     # macro_body_end += 32
   1953     addi_t1,t1,32
   1954     la_a0 &macro_body_end
   1955     st_t1,a0,0
   1956 
   1957     # proc_pos += 32
   1958     addi_t0,t0,32
   1959     la_a0 &proc_pos
   1960     st_t0,a0,0
   1961     la_br &def_body_loop
   1962     b
   1963 
   1964 :def_endm_after
   1965     # advance past the %endm token; the next token MUST be TOK_NEWLINE.
   1966     la_a0 &proc_pos
   1967     ld_t0,a0,0
   1968     addi_t0,t0,32
   1969     st_t0,a0,0
   1970     la_a1 &source_end
   1971     ld_t1,a1,0
   1972     la_br &err_bad_macro_header
   1973     beq_t0,t1
   1974     ld_a1,t0,0
   1975     li_a2 TOK_NEWLINE
   1976     la_br &err_bad_macro_header
   1977     bne_a1,a2
   1978     # Consume the trailing NEWLINE only when the directive started at
   1979     # line-start; mid-line directives leave the newline in the stream so
   1980     # the outer loop emits it (preserving line layout).
   1981     la_a0 &proc_line_start
   1982     ld_a1,a0,0
   1983     la_br &def_finish
   1984     beqz_a1
   1985     addi_t0,t0,32
   1986     la_a0 &proc_pos
   1987     st_t0,a0,0
   1988 
   1989 :def_finish
   1990     # m->body_end = macro_body_end
   1991     la_a1 &macro_body_end
   1992     ld_t2,a1,0
   1993     la_a0 &def_m_ptr
   1994     ld_t1,a0,0
   1995     li_a0 M1PP_MACRO_BODY_END_OFF
   1996     add_a0,t1,a0
   1997     st_t2,a0,0
   1998 
   1999     # macros_end += MACRO_RECORD_SIZE
   2000     la_a0 &macros_end
   2001     ld_t0,a0,0
   2002     li_a1 M1PP_MACRO_RECORD_SIZE
   2003     add_t0,t0,a1
   2004     st_t0,a0,0
   2005 
   2006     # caller resumes at line start
   2007     la_a0 &proc_line_start
   2008     li_a1 %1 %0
   2009     st_a1,a0,0
   2010     eret
   2011 
   2012 ## --- %struct / %enum directive ----------------------------------------------
   2013 ## define_fielded(a0=stride, a1=total_name_ptr, a2=total_name_len).
   2014 ## Parses `%struct NAME { f1 f2 ... }` or `%enum NAME { ... }` (caller has
   2015 ## already detected %struct / %enum at line start and primed proc_pos to
   2016 ## that token). Synthesizes N+1 zero-parameter macros — NAME.field_k -> k*stride
   2017 ## and NAME.<total_name> -> N*stride — by appending each {name, body-token}
   2018 ## pair into macros[] / macro_body_tokens[].
   2019 ##
   2020 ## All working state lives in BSS (df_* slots + df_name_scratch / df_digit_scratch)
   2021 ## because df_emit_field calls append_text, which clobbers caller-saved regs.
   2022 :define_fielded
   2023     enter_0
   2024 
   2025     # Save directive args to BSS.
   2026     la_a3 &df_stride
   2027     st_a0,a3,0
   2028     la_a3 &df_total_name_ptr
   2029     st_a1,a3,0
   2030     la_a3 &df_total_name_len
   2031     st_a2,a3,0
   2032 
   2033     # advance past the %struct / %enum directive token
   2034     la_a0 &proc_pos
   2035     ld_t0,a0,0
   2036     addi_t0,t0,32
   2037     st_t0,a0,0
   2038 
   2039     # Header is whitespace-insensitive: newlines between %struct/%enum and
   2040     # the NAME (and between NAME and '{') are skipped.
   2041     la_br &proc_skip_newlines
   2042     call
   2043 
   2044     # ---- header: name (WORD) ----
   2045     la_a0 &proc_pos
   2046     ld_t0,a0,0
   2047     la_a1 &source_end
   2048     ld_t1,a1,0
   2049     la_br &err_bad_directive
   2050     beq_t0,t1
   2051     ld_a1,t0,0
   2052     li_a2 TOK_WORD
   2053     la_br &err_bad_directive
   2054     bne_a1,a2
   2055 
   2056     # df_base_ptr = tok.text_ptr; df_base_len = tok.text_len
   2057     ld_a2,t0,8
   2058     la_a3 &df_base_ptr
   2059     st_a2,a3,0
   2060     ld_a2,t0,16
   2061     la_a3 &df_base_len
   2062     st_a2,a3,0
   2063 
   2064     # advance past the base name
   2065     addi_t0,t0,32
   2066     la_a0 &proc_pos
   2067     st_t0,a0,0
   2068 
   2069 ## skip NEWLINE tokens before '{' (tolerates `%struct NAME\n{ ... }`)
   2070 :df_skip_nl_before_lbrace
   2071     la_a0 &proc_pos
   2072     ld_t0,a0,0
   2073     la_a1 &source_end
   2074     ld_t1,a1,0
   2075     la_br &err_bad_directive
   2076     beq_t0,t1
   2077     ld_a1,t0,0
   2078     li_a2 TOK_NEWLINE
   2079     la_br &df_require_lbrace
   2080     bne_a1,a2
   2081     addi_t0,t0,32
   2082     la_a0 &proc_pos
   2083     st_t0,a0,0
   2084     la_br &df_skip_nl_before_lbrace
   2085     b
   2086 
   2087 :df_require_lbrace
   2088     # expect LBRACE
   2089     li_a2 TOK_LBRACE
   2090     la_br &err_bad_directive
   2091     bne_a1,a2
   2092 
   2093     # advance past '{'
   2094     addi_t0,t0,32
   2095     la_a0 &proc_pos
   2096     st_t0,a0,0
   2097 
   2098     # df_index = 0
   2099     li_a0 %0 %0
   2100     la_a1 &df_index
   2101     st_a0,a1,0
   2102 
   2103 ## field loop: skip comma/newline separators, stop at '}', else consume a WORD.
   2104 :df_field_loop
   2105     la_a0 &proc_pos
   2106     ld_t0,a0,0
   2107     la_a1 &source_end
   2108     ld_t1,a1,0
   2109     la_br &err_unterminated_directive
   2110     beq_t0,t1
   2111     ld_a1,t0,0
   2112 
   2113     # separator: COMMA or NEWLINE -> advance and reloop
   2114     li_a2 TOK_COMMA
   2115     la_br &df_field_skip_sep
   2116     beq_a1,a2
   2117     li_a2 TOK_NEWLINE
   2118     la_br &df_field_skip_sep
   2119     beq_a1,a2
   2120 
   2121     # end-of-list marker '}' -> break
   2122     li_a2 TOK_RBRACE
   2123     la_br &df_fields_done
   2124     beq_a1,a2
   2125 
   2126     # else must be a WORD
   2127     li_a2 TOK_WORD
   2128     la_br &err_bad_directive
   2129     bne_a1,a2
   2130 
   2131     # df_suffix_ptr = tok.text_ptr; df_suffix_len = tok.text_len
   2132     ld_a2,t0,8
   2133     la_a3 &df_suffix_ptr
   2134     st_a2,a3,0
   2135     ld_a2,t0,16
   2136     la_a3 &df_suffix_len
   2137     st_a2,a3,0
   2138 
   2139     # df_value = df_index * df_stride
   2140     la_a0 &df_index
   2141     ld_t1,a0,0
   2142     la_a0 &df_stride
   2143     ld_t2,a0,0
   2144     mul_a0,t1,t2
   2145     la_a1 &df_value
   2146     st_a0,a1,0
   2147 
   2148     # synthesize the field macro
   2149     la_br &df_emit_field
   2150     call
   2151 
   2152     # df_index++
   2153     la_a0 &df_index
   2154     ld_t1,a0,0
   2155     addi_t1,t1,1
   2156     st_t1,a0,0
   2157 
   2158     # advance past the field word
   2159     la_a0 &proc_pos
   2160     ld_t0,a0,0
   2161     addi_t0,t0,32
   2162     st_t0,a0,0
   2163     la_br &df_field_loop
   2164     b
   2165 
   2166 :df_field_skip_sep
   2167     addi_t0,t0,32
   2168     la_a0 &proc_pos
   2169     st_t0,a0,0
   2170     la_br &df_field_loop
   2171     b
   2172 
   2173 :df_fields_done
   2174     # advance past '}'
   2175     addi_t0,t0,32
   2176     la_a0 &proc_pos
   2177     st_t0,a0,0
   2178 
   2179     # ---- emit totalizer: df_suffix <- df_total_name; df_value = N * stride ----
   2180     la_a0 &df_total_name_ptr
   2181     ld_t0,a0,0
   2182     la_a1 &df_suffix_ptr
   2183     st_t0,a1,0
   2184     la_a0 &df_total_name_len
   2185     ld_t0,a0,0
   2186     la_a1 &df_suffix_len
   2187     st_t0,a1,0
   2188 
   2189     la_a0 &df_index
   2190     ld_t1,a0,0
   2191     la_a0 &df_stride
   2192     ld_t2,a0,0
   2193     mul_a0,t1,t2
   2194     la_a1 &df_value
   2195     st_a0,a1,0
   2196 
   2197     la_br &df_emit_field
   2198     call
   2199 
   2200     # Strict: the closing '}' must be immediately followed by TOK_NEWLINE.
   2201     # Consume that newline only when the directive started at line-start,
   2202     # mirroring %endm / %endframe.
   2203     la_a0 &proc_pos
   2204     ld_t0,a0,0
   2205     la_a1 &source_end
   2206     ld_t1,a1,0
   2207     la_br &err_bad_directive
   2208     beq_t0,t1
   2209     ld_a1,t0,0
   2210     li_a2 TOK_NEWLINE
   2211     la_br &err_bad_directive
   2212     bne_a1,a2
   2213     la_a0 &proc_line_start
   2214     ld_a1,a0,0
   2215     la_br &df_finish
   2216     beqz_a1
   2217     addi_t0,t0,32
   2218     la_a0 &proc_pos
   2219     st_t0,a0,0
   2220 
   2221 :df_finish
   2222     la_a0 &proc_line_start
   2223     li_a1 %1 %0
   2224     st_a1,a0,0
   2225     eret
   2226 
   2227 ## df_emit_field(): read df_base_*, df_suffix_*, df_value from BSS; synthesize
   2228 ## one macro record + one body token. Builds the "NAME.field" identifier in
   2229 ## df_name_scratch and the decimal body text via df_render_decimal, then
   2230 ## copies both into text_buf via append_text so they outlive the scratch.
   2231 :df_emit_field
   2232     enter_0
   2233 
   2234     # macros_end capacity check
   2235     la_a0 &macros_end
   2236     ld_t0,a0,0
   2237     la_a1 &macros_ptr
   2238     ld_a1,a1,0
   2239     li_a2 M1PP_MACROS_CAP
   2240     add_a1,a1,a2
   2241     la_br &err_too_many_macros
   2242     beq_t0,a1
   2243 
   2244     # ---- assemble "BASE.SUFFIX" into df_name_scratch ----
   2245     # copy base bytes
   2246     la_a0 &df_base_ptr
   2247     ld_t0,a0,0
   2248     la_a0 &df_base_len
   2249     ld_t1,a0,0
   2250     la_t2 &df_name_scratch_ptr
   2251     ld_t2,t2,0
   2252     li_a3 %0 %0
   2253 :df_ef_base_loop
   2254     la_br &df_ef_base_done
   2255     beq_a3,t1
   2256     add_a0,t0,a3
   2257     lb_a0,a0,0
   2258     add_a1,t2,a3
   2259     sb_a0,a1,0
   2260     addi_a3,a3,1
   2261     la_br &df_ef_base_loop
   2262     b
   2263 :df_ef_base_done
   2264     # scratch[base_len] = '.'
   2265     add_a1,t2,t1
   2266     li_a0 %46 %0
   2267     sb_a0,a1,0
   2268 
   2269     # copy suffix bytes into scratch[base_len + 1 ..]
   2270     la_a0 &df_suffix_ptr
   2271     ld_t0,a0,0
   2272     la_a0 &df_suffix_len
   2273     ld_t1,a0,0
   2274     addi_a1,a1,1
   2275     li_a3 %0 %0
   2276 :df_ef_suffix_loop
   2277     la_br &df_ef_suffix_done
   2278     beq_a3,t1
   2279     add_a0,t0,a3
   2280     lb_a0,a0,0
   2281     add_a2,a1,a3
   2282     sb_a0,a2,0
   2283     addi_a3,a3,1
   2284     la_br &df_ef_suffix_loop
   2285     b
   2286 :df_ef_suffix_done
   2287 
   2288     # name_len = base_len + 1 + suffix_len
   2289     la_a0 &df_base_len
   2290     ld_t0,a0,0
   2291     la_a0 &df_suffix_len
   2292     ld_t1,a0,0
   2293     add_t0,t0,t1
   2294     addi_t0,t0,1
   2295     la_a1 &df_name_len
   2296     st_t0,a1,0
   2297 
   2298     # durable_name = append_text(&df_name_scratch, name_len)
   2299     la_a0 &df_name_scratch_ptr
   2300     ld_a0,a0,0
   2301     mov_a1,t0
   2302     la_br &append_text
   2303     call
   2304     # a0 = durable_name ptr
   2305 
   2306     # m = macros_end; m->name.ptr = durable_name; m->name.len = name_len
   2307     la_a1 &macros_end
   2308     ld_t2,a1,0
   2309     st_a0,t2,0
   2310     la_a0 &df_name_len
   2311     ld_a0,a0,0
   2312     st_a0,t2,8
   2313 
   2314     # m->param_count = 0  (params[] left zeroed; not read when count == 0)
   2315     li_a0 %0 %0
   2316     st_a0,t2,16
   2317 
   2318     # render df_value into df_digit_scratch (reverse fill)
   2319     la_br &df_render_decimal
   2320     call
   2321 
   2322     # durable_digits = append_text(&df_digit_cursor, df_digit_count)
   2323     la_a0 &df_digit_cursor
   2324     ld_a0,a0,0
   2325     la_a1 &df_digit_count
   2326     ld_a1,a1,0
   2327     la_br &append_text
   2328     call
   2329     # a0 = durable_digits
   2330 
   2331     # macro_body_end capacity check
   2332     la_a1 &macro_body_end
   2333     ld_t0,a1,0
   2334     la_a2 &macro_body_tokens_ptr
   2335     ld_a2,a2,0
   2336     sub_a3,t0,a2
   2337     addi_a3,a3,32
   2338     li_t2 M1PP_MACRO_BODY_CAP
   2339     la_br &err_macro_body_overflow
   2340     blt_t2,a3
   2341 
   2342     # body_tok = TOK_WORD { durable_digits, df_digit_count, tight=0 }
   2343     li_a1 TOK_WORD
   2344     st_a1,t0,0
   2345     st_a0,t0,8
   2346     la_a2 &df_digit_count
   2347     ld_a2,a2,0
   2348     st_a2,t0,16
   2349     li_a1 %0 %0
   2350     st_a1,t0,24
   2351 
   2352     # m->body_start = macro_body_end (the slot we just wrote)
   2353     la_a0 &macros_end
   2354     ld_t2,a0,0
   2355     li_a1 M1PP_MACRO_BODY_START_OFF
   2356     add_a1,t2,a1
   2357     st_t0,a1,0
   2358 
   2359     # macro_body_end += 24
   2360     addi_t0,t0,32
   2361     la_a1 &macro_body_end
   2362     st_t0,a1,0
   2363 
   2364     # m->body_end = macro_body_end
   2365     li_a1 M1PP_MACRO_BODY_END_OFF
   2366     add_a1,t2,a1
   2367     st_t0,a1,0
   2368 
   2369     # macros_end += MACRO_RECORD_SIZE
   2370     li_a0 M1PP_MACRO_RECORD_SIZE
   2371     add_t2,t2,a0
   2372     la_a1 &macros_end
   2373     st_t2,a1,0
   2374 
   2375     eret
   2376 
   2377 ## df_render_decimal(): reads df_value; writes a reverse-filled decimal
   2378 ## rendering into df_digit_scratch[cursor..end) and stores df_digit_count +
   2379 ## df_digit_cursor for a subsequent append_text call. Leaf.
   2380 :df_render_decimal
   2381     la_a0 &df_value
   2382     ld_t0,a0,0
   2383     la_t1 &df_digit_scratch
   2384     li_a2 %24 %0
   2385     add_t1,t1,a2
   2386     mov_t2,t1
   2387 
   2388     # special-case v == 0 -> single '0'
   2389     la_br &df_rd_loop
   2390     bnez_t0
   2391     addi_t2,t2,neg1
   2392     li_a0 %48 %0
   2393     sb_a0,t2,0
   2394     la_br &df_rd_done
   2395     b
   2396 :df_rd_loop
   2397     la_br &df_rd_done
   2398     beqz_t0
   2399     mov_a0,t0
   2400     li_a1 %10 %0
   2401     rem_a2,a0,a1
   2402     addi_a2,a2,48
   2403     addi_t2,t2,neg1
   2404     sb_a2,t2,0
   2405     mov_a0,t0
   2406     li_a1 %10 %0
   2407     div_a0,a0,a1
   2408     mov_t0,a0
   2409     la_br &df_rd_loop
   2410     b
   2411 :df_rd_done
   2412     la_a1 &df_digit_scratch
   2413     li_a2 %24 %0
   2414     add_a1,a1,a2
   2415     sub_a0,a1,t2
   2416     la_a1 &df_digit_count
   2417     st_a0,a1,0
   2418     la_a1 &df_digit_cursor
   2419     st_t2,a1,0
   2420     ret
   2421 
   2422 ## ============================================================================
   2423 ## --- Stream stack + expansion-pool lifetime ---------------------------------
   2424 ## ============================================================================
   2425 ## process_tokens drives a stack of token streams. The source token array is
   2426 ## pushed first; each macro expansion or %select chosen-branch pushes a fresh
   2427 ## stream backed by a slice of expand_pool, popping rewinds pool_used to the
   2428 ## stream's pool_mark.
   2429 
   2430 ## push_stream_span(a0=start_tok, a1=end_tok, a2=pool_mark) -> void (fatal on overflow)
   2431 ## Push Stream { start = pos = a0, end = a1, line_start = 1, pool_mark = a2 }
   2432 ## onto streams[]. Bumps stream_top. pool_mark is a byte offset into
   2433 ## expand_pool, or -1 for a source-owned stream (pop_stream won't rewind).
   2434 ##
   2435 ## stream_top is maintained as a byte offset into streams[] (count * 40),
   2436 ## matching the running-tail-pointer pattern used by source_end / macros_end.
   2437 ## Reads/writes: streams, stream_top. Leaf.
   2438 :push_stream_span
   2439     # new_top = stream_top + STREAM_SIZE; if (cap < new_top) fatal
   2440     la_t0 &stream_top
   2441     ld_t1,t0,0
   2442     li_t2 M1PP_STREAM_SIZE
   2443     add_t2,t1,t2
   2444     li_a3 M1PP_STREAM_STACK_CAP
   2445     la_br &err_token_overflow
   2446     blt_a3,t2
   2447 
   2448     # s = &streams[stream_top]
   2449     la_a3 &streams_ptr
   2450     ld_a3,a3,0
   2451     add_a3,a3,t1
   2452 
   2453     # s->start = a0; s->end = a1; s->pos = a0; s->line_start = 1; s->pool_mark = a2
   2454     st_a0,a3,0
   2455     st_a1,a3,8
   2456     st_a0,a3,16
   2457     li_t1 %1 %0
   2458     st_t1,a3,24
   2459     st_a2,a3,32
   2460 
   2461     # stream_top = new_top
   2462     st_t2,t0,0
   2463     ret
   2464 
   2465 ## current_stream() -> a0 = &streams[stream_top-1], or 0 if empty. Leaf.
   2466 ## stream_top is a byte offset, so &streams[top-1] = streams + stream_top - 40.
   2467 ## Reads: streams, stream_top.
   2468 :current_stream
   2469     la_a0 &stream_top
   2470     ld_t0,a0,0
   2471     la_br &current_stream_empty
   2472     beqz_t0
   2473     la_a0 &streams_ptr
   2474     ld_a0,a0,0
   2475     add_a0,a0,t0
   2476     li_t1 M1PP_STREAM_SIZE
   2477     sub_a0,a0,t1
   2478     ret
   2479 :current_stream_empty
   2480     li_a0 %0 %0
   2481     ret
   2482 
   2483 ## pop_stream() -> void. Leaf.
   2484 ## Decrement stream_top. If the popped stream's pool_mark >= 0, restore
   2485 ## pool_used = pool_mark (reclaim the expansion-pool space it used).
   2486 ## Reads/writes: streams, stream_top, pool_used.
   2487 :pop_stream
   2488     la_a0 &stream_top
   2489     ld_t0,a0,0
   2490     la_br &pop_stream_done
   2491     beqz_t0
   2492     li_t1 M1PP_STREAM_SIZE
   2493     sub_t0,t0,t1
   2494     st_t0,a0,0
   2495 
   2496     # mark = popped->pool_mark
   2497     la_a1 &streams_ptr
   2498     ld_a1,a1,0
   2499     add_a1,a1,t0
   2500     ld_t0,a1,32
   2501 
   2502     # if (mark < 0) skip; else pool_used = mark
   2503     la_br &pop_stream_done
   2504     bltz_t0
   2505     la_a1 &pool_used
   2506     st_t0,a1,0
   2507 :pop_stream_done
   2508     ret
   2509 
   2510 ## copy_span_to_pool(a0=start_tok, a1=end_tok) -> void (fatal on pool overflow)
   2511 ## Append each 32-byte Token in [start, end) to expand_pool at pool_used,
   2512 ## advancing pool_used accordingly. Preserves tight bit at +24.
   2513 ## Reads/writes: expand_pool, pool_used. Leaf.
   2514 :copy_span_to_pool
   2515 :cstp_loop
   2516     # if (start == end) done
   2517     la_br &cstp_done
   2518     beq_a0,a1
   2519 
   2520     # bounds: pool_used + 32 must fit in EXPAND_CAP
   2521     la_a2 &pool_used
   2522     ld_t0,a2,0
   2523     addi_t1,t0,32
   2524     li_t2 M1PP_EXPAND_CAP
   2525     la_br &err_token_overflow
   2526     blt_t2,t1
   2527 
   2528     # dst = &expand_pool[pool_used]
   2529     la_a3 &expand_pool_ptr
   2530     ld_a3,a3,0
   2531     add_a3,a3,t0
   2532 
   2533     # copy 32 bytes (4 × u64)
   2534     ld_t1,a0,0
   2535     st_t1,a3,0
   2536     ld_t1,a0,8
   2537     st_t1,a3,8
   2538     ld_t1,a0,16
   2539     st_t1,a3,16
   2540     ld_t1,a0,24
   2541     st_t1,a3,24
   2542 
   2543     # pool_used += 32; start += 32
   2544     addi_t0,t0,32
   2545     st_t0,a2,0
   2546     addi_a0,a0,32
   2547     la_br &cstp_loop
   2548     b
   2549 :cstp_done
   2550     ret
   2551 
   2552 ## push_pool_stream_from_mark(a0=mark) -> void (fatal on overflow)
   2553 ## If pool_used == mark (empty expansion), do nothing and return.
   2554 ## Otherwise push_stream_span(expand_pool+mark, expand_pool+pool_used, mark).
   2555 ## Reads/writes: expand_pool, pool_used, streams, stream_top. Non-leaf:
   2556 ## needs a frame so the call to push_stream_span doesn't clobber LR.
   2557 :push_pool_stream_from_mark
   2558     enter_0
   2559     # if (pool_used == mark) return
   2560     la_a1 &pool_used
   2561     ld_t0,a1,0
   2562     la_br &ppsfm_done
   2563     beq_t0,a0
   2564 
   2565     # push_stream_span(expand_pool+mark, expand_pool+pool_used, mark)
   2566     la_a2 &expand_pool_ptr
   2567     ld_a2,a2,0
   2568     mov_t1,a0
   2569     add_a0,a2,a0
   2570     add_a1,a2,t0
   2571     mov_a2,t1
   2572     la_br &push_stream_span
   2573     call
   2574 :ppsfm_done
   2575     eret
   2576 
   2577 ## ============================================================================
   2578 ## --- Argument parsing -------------------------------------------------------
   2579 ## ============================================================================
   2580 
   2581 ## parse_args(a0=lparen_tok, a1=limit_tok) -> void (fatal on unterminated/overflow)
   2582 ## Scan tokens from lparen+1 up to limit, tracking paren depth. At depth 1 each
   2583 ## TOK_COMMA ends one arg and starts the next; the matching TOK_RPAREN at
   2584 ## depth 0 ends the last arg. An empty `()` is arg_count = 0.
   2585 ##
   2586 ## Writes globals:
   2587 ##   arg_starts[i]  = first token of arg i
   2588 ##   arg_ends[i]    = one past last token of arg i
   2589 ##   arg_count      = number of args (0..16)
   2590 ##   call_end_pos   = one past the closing RPAREN
   2591 ##
   2592 ## Fatal on: > 16 args, reaching limit without matching RPAREN.
   2593 :parse_args
   2594     # tok = lparen + 1; arg_start = tok; depth = 1; arg_index = 0; brace_depth = 0
   2595     addi_a0,a0,32
   2596     la_a2 &pa_pos
   2597     st_a0,a2,0
   2598     la_a2 &pa_arg_start
   2599     st_a0,a2,0
   2600     la_a2 &pa_limit
   2601     st_a1,a2,0
   2602     li_a2 %1 %0
   2603     la_a3 &pa_depth
   2604     st_a2,a3,0
   2605     li_a2 %0 %0
   2606     la_a3 &pa_arg_index
   2607     st_a2,a3,0
   2608     li_a2 %0 %0
   2609     la_a3 &pa_brace_depth
   2610     st_a2,a3,0
   2611 
   2612     # args_have_paste = 0 — set to 1 below if any TOK_PASTE appears in the
   2613     # call's argument span. expand_macro_tokens snapshots this right after
   2614     # parse_args returns; bare arg copies preserve embedded ## tokens, and
   2615     # the snapshot tells us whether we still have to run paste_pool_range
   2616     # even when the body itself contains no ##.
   2617     li_a2 %0 %0
   2618     la_a3 &args_have_paste
   2619     st_a2,a3,0
   2620 
   2621 :pa_loop
   2622     # if (tok >= limit) fatal unterminated
   2623     la_a0 &pa_pos
   2624     ld_t0,a0,0
   2625     la_a1 &pa_limit
   2626     ld_t1,a1,0
   2627     la_br &err_unterminated_macro
   2628     beq_t0,t1
   2629 
   2630     # kind = tok->kind
   2631     ld_a2,t0,0
   2632 
   2633     # if (kind == TOK_PASTE) { args_have_paste = 1; fall through to default-advance }
   2634     li_a3 TOK_PASTE
   2635     la_br &pa_kind_check
   2636     bne_a2,a3
   2637     li_a3 %1 %0
   2638     la_a0 &args_have_paste
   2639     st_a3,a0,0
   2640 
   2641 :pa_kind_check
   2642     # if (kind == TOK_LPAREN) { depth++; tok++; loop }
   2643     li_a3 TOK_LPAREN
   2644     la_br &pa_lparen
   2645     beq_a2,a3
   2646     li_a3 TOK_RPAREN
   2647     la_br &pa_rparen
   2648     beq_a2,a3
   2649     li_a3 TOK_COMMA
   2650     la_br &pa_maybe_comma
   2651     beq_a2,a3
   2652     li_a3 TOK_LBRACE
   2653     la_br &pa_lbrace
   2654     beq_a2,a3
   2655     li_a3 TOK_RBRACE
   2656     la_br &pa_rbrace
   2657     beq_a2,a3
   2658 
   2659     # default: tok++
   2660     addi_t0,t0,32
   2661     la_a0 &pa_pos
   2662     st_t0,a0,0
   2663     la_br &pa_loop
   2664     b
   2665 
   2666 :pa_lparen
   2667     la_a0 &pa_depth
   2668     ld_t1,a0,0
   2669     addi_t1,t1,1
   2670     st_t1,a0,0
   2671     addi_t0,t0,32
   2672     la_a0 &pa_pos
   2673     st_t0,a0,0
   2674     la_br &pa_loop
   2675     b
   2676 
   2677 :pa_rparen
   2678     # depth--
   2679     la_a0 &pa_depth
   2680     ld_t1,a0,0
   2681     addi_t1,t1,neg1
   2682     st_t1,a0,0
   2683     # if (depth != 0) tok++; loop
   2684     la_br &pa_rparen_close
   2685     beqz_t1
   2686     addi_t0,t0,32
   2687     la_a0 &pa_pos
   2688     st_t0,a0,0
   2689     la_br &pa_loop
   2690     b
   2691 
   2692 :pa_rparen_close
   2693     # depth == 0: if brace_depth != 0 -> unbalanced braces
   2694     la_a0 &pa_brace_depth
   2695     ld_t1,a0,0
   2696     la_br &err_unbalanced_braces
   2697     bnez_t1
   2698     # close out the call.
   2699     # arg_start (BSS), arg_index (BSS), tok = current pos.
   2700     la_a0 &pa_arg_start
   2701     ld_a1,a0,0
   2702     la_a0 &pa_arg_index
   2703     ld_a2,a0,0
   2704 
   2705     # if (arg_start == tok && arg_index == 0) -> arg_count = 0
   2706     la_br &pa_close_with_arg
   2707     bne_a1,t0
   2708     la_br &pa_close_with_arg
   2709     bnez_a2
   2710 
   2711     # empty (): arg_count = 0
   2712     li_a3 %0 %0
   2713     la_a0 &arg_count
   2714     st_a3,a0,0
   2715     la_br &pa_finish
   2716     b
   2717 
   2718 :pa_close_with_arg
   2719     # if (arg_index >= 16) fatal: branch to ok only if arg_index < 16
   2720     li_a3 M1PP_MAX_PARAMS
   2721     la_br &pa_close_with_arg_ok
   2722     blt_a2,a3
   2723     la_br &err_bad_macro_header
   2724     b
   2725 :pa_close_with_arg_ok
   2726     # arg_starts[arg_index] = arg_start; arg_ends[arg_index] = tok
   2727     la_a3 &arg_starts_ptr
   2728     ld_a3,a3,0
   2729     shli_t1,a2,3
   2730     add_a3,a3,t1
   2731     st_a1,a3,0
   2732     la_a3 &arg_ends_ptr
   2733     ld_a3,a3,0
   2734     add_a3,a3,t1
   2735     st_t0,a3,0
   2736     # arg_count = arg_index + 1
   2737     addi_a2,a2,1
   2738     la_a0 &arg_count
   2739     st_a2,a0,0
   2740 
   2741 :pa_finish
   2742     # call_end_pos = tok + 24
   2743     addi_t0,t0,32
   2744     la_a0 &call_end_pos
   2745     st_t0,a0,0
   2746     ret
   2747 
   2748 :pa_maybe_comma
   2749     # only split at depth == 1
   2750     la_a0 &pa_depth
   2751     ld_t1,a0,0
   2752     li_a3 %1 %0
   2753     la_br &pa_default_advance
   2754     bne_t1,a3
   2755     # and only when brace_depth == 0
   2756     la_a0 &pa_brace_depth
   2757     ld_t1,a0,0
   2758     la_br &pa_default_advance
   2759     bnez_t1
   2760 
   2761     # depth == 1 && brace_depth == 0 split: append (arg_start, tok) at arg_index
   2762     la_a0 &pa_arg_index
   2763     ld_a2,a0,0
   2764     li_a3 M1PP_MAX_PARAMS
   2765     la_br &pa_comma_ok
   2766     blt_a2,a3
   2767     la_br &err_bad_macro_header
   2768     b
   2769 :pa_comma_ok
   2770     la_a0 &pa_arg_start
   2771     ld_a1,a0,0
   2772     la_a3 &arg_starts_ptr
   2773     ld_a3,a3,0
   2774     shli_t1,a2,3
   2775     add_a3,a3,t1
   2776     st_a1,a3,0
   2777     la_a3 &arg_ends_ptr
   2778     ld_a3,a3,0
   2779     add_a3,a3,t1
   2780     st_t0,a3,0
   2781     # arg_index++
   2782     addi_a2,a2,1
   2783     la_a0 &pa_arg_index
   2784     st_a2,a0,0
   2785     # arg_start = tok + 24
   2786     addi_t0,t0,32
   2787     la_a0 &pa_arg_start
   2788     st_t0,a0,0
   2789     la_a0 &pa_pos
   2790     st_t0,a0,0
   2791     la_br &pa_loop
   2792     b
   2793 
   2794 :pa_default_advance
   2795     # comma at depth != 1: just advance
   2796     addi_t0,t0,32
   2797     la_a0 &pa_pos
   2798     st_t0,a0,0
   2799     la_br &pa_loop
   2800     b
   2801 
   2802 :pa_lbrace
   2803     # brace_depth++; tok++
   2804     la_a0 &pa_brace_depth
   2805     ld_t1,a0,0
   2806     addi_t1,t1,1
   2807     st_t1,a0,0
   2808     addi_t0,t0,32
   2809     la_a0 &pa_pos
   2810     st_t0,a0,0
   2811     la_br &pa_loop
   2812     b
   2813 
   2814 :pa_rbrace
   2815     # if (brace_depth <= 0) fatal unbalanced braces
   2816     la_a0 &pa_brace_depth
   2817     ld_t1,a0,0
   2818     la_br &err_unbalanced_braces
   2819     beqz_t1
   2820     # brace_depth--; tok++
   2821     addi_t1,t1,neg1
   2822     st_t1,a0,0
   2823     addi_t0,t0,32
   2824     la_a0 &pa_pos
   2825     st_t0,a0,0
   2826     la_br &pa_loop
   2827     b
   2828 
   2829 ## ============================================================================
   2830 ## --- Macro lookup + call expansion ------------------------------------------
   2831 ## ============================================================================
   2832 
   2833 ## find_macro(a0=tok) -> a0 = Macro* or 0. Leaf.
   2834 ## Non-zero only if tok is TOK_WORD, text.len >= 2, text[0] == '%', and
   2835 ## (text+1, len-1) equals macros[i].name for some i. First match wins.
   2836 ## Reads: macros, macros_end.
   2837 :find_macro
   2838     # if (tok.kind != TOK_WORD) return 0
   2839     ld_a1,a0,0
   2840     li_a2 TOK_WORD
   2841     la_br &find_macro_zero
   2842     bne_a1,a2
   2843 
   2844     # if (tok.text.len < 2) return 0
   2845     ld_a2,a0,16
   2846     li_a3 %2 %0
   2847     la_br &find_macro_zero
   2848     blt_a2,a3
   2849 
   2850     # if (tok.text[0] != '%') return 0
   2851     ld_a1,a0,8
   2852     lb_a3,a1,0
   2853     li_t0 %37 %0
   2854     la_br &find_macro_zero
   2855     bne_a3,t0
   2856 
   2857     # name_ptr = tok.text + 1; name_len = tok.text.len - 1
   2858     addi_a1,a1,1
   2859     addi_a2,a2,neg1
   2860 
   2861     # m = &macros[0]; m_end = macros_end
   2862     la_a3 &macros_ptr
   2863     ld_a3,a3,0
   2864     la_t0 &macros_end
   2865     ld_t0,t0,0
   2866 
   2867 :find_macro_loop
   2868     # if (m == macros_end) return 0
   2869     la_br &find_macro_zero
   2870     beq_a3,t0
   2871 
   2872     # if (m->name.len != name_len) advance
   2873     ld_t1,a3,8
   2874     la_br &find_macro_next
   2875     bne_t1,a2
   2876 
   2877     # byte-compare m->name.ptr vs name_ptr for name_len bytes
   2878     ld_t1,a3,0
   2879     li_t2 %0 %0
   2880 :find_macro_cmp
   2881     la_br &find_macro_match
   2882     beq_t2,a2
   2883     add_a0,t1,t2
   2884     lb_a0,a0,0
   2885     add_t0,a1,t2
   2886     lb_t0,t0,0
   2887     la_br &find_macro_next
   2888     bne_a0,t0
   2889     addi_t2,t2,1
   2890     la_br &find_macro_cmp
   2891     b
   2892 
   2893 :find_macro_next
   2894     # m += M1PP_MACRO_RECORD_SIZE
   2895     li_t1 M1PP_MACRO_RECORD_SIZE
   2896     add_a3,a3,t1
   2897     # reload macros_end (clobbered by the comparisons)
   2898     la_t0 &macros_end
   2899     ld_t0,t0,0
   2900     la_br &find_macro_loop
   2901     b
   2902 
   2903 :find_macro_match
   2904     mov_a0,a3
   2905     ret
   2906 
   2907 :find_macro_zero
   2908     li_a0 %0 %0
   2909     ret
   2910 
   2911 ## find_param(a0=macro_ptr, a1=tok) -> a0 = (index+1) or 0. Leaf.
   2912 ## Linear search over macro->params[0..param_count). Non-WORD tok -> 0, so
   2913 ## callers can test the return against zero without pre-filtering.
   2914 :find_param
   2915     # if (tok.kind != TOK_WORD) return 0
   2916     ld_a2,a1,0
   2917     li_a3 TOK_WORD
   2918     la_br &find_param_zero
   2919     bne_a2,a3
   2920 
   2921     # param_count = macro->param_count
   2922     ld_a2,a0,16
   2923     la_br &find_param_zero
   2924     beqz_a2
   2925 
   2926     # Spill bases into BSS so the cmp loop has free temp regs.
   2927     #   fp_macro     = macro_ptr
   2928     #   fp_tok       = tok ptr
   2929     #   fp_pcount    = param_count
   2930     #   fp_idx       = current param index
   2931     la_a3 &fp_macro
   2932     st_a0,a3,0
   2933     la_a3 &fp_tok
   2934     st_a1,a3,0
   2935     la_a3 &fp_pcount
   2936     st_a2,a3,0
   2937     li_a3 %0 %0
   2938     la_a0 &fp_idx
   2939     st_a3,a0,0
   2940 
   2941 :find_param_outer
   2942     # idx, pcount
   2943     la_a0 &fp_idx
   2944     ld_t0,a0,0
   2945     la_a0 &fp_pcount
   2946     ld_a1,a0,0
   2947     la_br &find_param_zero
   2948     beq_t0,a1
   2949 
   2950     # param_ptr = fp_macro + 24 + idx * 16   (macro record params start at +24)
   2951     la_a0 &fp_macro
   2952     ld_a2,a0,0
   2953     addi_a2,a2,24
   2954     shli_a3,t0,4
   2955     add_a2,a2,a3
   2956 
   2957     # tok ptr
   2958     la_a0 &fp_tok
   2959     ld_a3,a0,0
   2960 
   2961     # Compare lengths.
   2962     ld_t1,a2,8
   2963     ld_t2,a3,16
   2964     la_br &find_param_next
   2965     bne_t1,t2
   2966 
   2967     # Lengths match. Byte-compare param.ptr vs tok.text.ptr for t1 bytes.
   2968     # After this point we either return or restart the outer loop, so
   2969     # all caller-saved regs are free.
   2970     ld_a0,a2,0
   2971     ld_a1,a3,8
   2972     li_t0 %0 %0
   2973 :find_param_cmp
   2974     la_br &find_param_match
   2975     beq_t0,t1
   2976     add_t2,a0,t0
   2977     lb_t2,t2,0
   2978     add_a2,a1,t0
   2979     lb_a2,a2,0
   2980     la_br &find_param_next
   2981     bne_t2,a2
   2982     addi_t0,t0,1
   2983     la_br &find_param_cmp
   2984     b
   2985 
   2986 :find_param_next
   2987     # idx++
   2988     la_a0 &fp_idx
   2989     ld_t0,a0,0
   2990     addi_t0,t0,1
   2991     st_t0,a0,0
   2992     la_br &find_param_outer
   2993     b
   2994 
   2995 :find_param_match
   2996     # return idx + 1
   2997     la_a0 &fp_idx
   2998     ld_a0,a0,0
   2999     addi_a0,a0,1
   3000     ret
   3001 
   3002 :find_param_zero
   3003     li_a0 %0 %0
   3004     ret
   3005 
   3006 ## arg_is_braced(a0=start, a1=end) -> a0 = 1 if the span wraps in a matching
   3007 ## outer { ... } pair (outer RBRACE is the same-level mate of the leading
   3008 ## LBRACE), else 0. Leaf.
   3009 :arg_is_braced
   3010     # if (end - start < 2 tokens = 64 bytes) return 0
   3011     sub_a2,a1,a0
   3012     li_a3 %64 %0
   3013     la_br &aib_zero
   3014     blt_a2,a3
   3015 
   3016     # if (start->kind != TOK_LBRACE) return 0
   3017     ld_a2,a0,0
   3018     li_a3 TOK_LBRACE
   3019     la_br &aib_zero
   3020     bne_a2,a3
   3021 
   3022     # if ((end - 24)->kind != TOK_RBRACE) return 0
   3023     addi_t0,a1,neg32
   3024     ld_a2,t0,0
   3025     li_a3 TOK_RBRACE
   3026     la_br &aib_zero
   3027     bne_a2,a3
   3028 
   3029     # walk tokens tracking depth; if depth hits 0 before reaching end-24,
   3030     # the leading LBRACE doesn't match the trailing RBRACE -> return 0.
   3031     # t0 = tok, t1 = depth, t2 = last_tok = end - 24
   3032     mov_t0,a0
   3033     li_t1 %0 %0
   3034     addi_t2,a1,neg32
   3035 :aib_loop
   3036     la_br &aib_done
   3037     beq_t0,a1
   3038     ld_a2,t0,0
   3039     li_a3 TOK_LBRACE
   3040     la_br &aib_incr
   3041     beq_a2,a3
   3042     li_a3 TOK_RBRACE
   3043     la_br &aib_decr
   3044     beq_a2,a3
   3045     # non-brace: advance
   3046     addi_t0,t0,32
   3047     la_br &aib_loop
   3048     b
   3049 :aib_incr
   3050     addi_t1,t1,1
   3051     addi_t0,t0,32
   3052     la_br &aib_loop
   3053     b
   3054 :aib_decr
   3055     addi_t1,t1,neg1
   3056     # if (depth == 0 && tok != end - 24) -> not wrapping
   3057     la_br &aib_decr_skip
   3058     bnez_t1
   3059     la_br &aib_zero
   3060     bne_t0,t2
   3061 :aib_decr_skip
   3062     addi_t0,t0,32
   3063     la_br &aib_loop
   3064     b
   3065 :aib_done
   3066     # return (depth == 0) ? 1 : 0
   3067     la_br &aib_zero
   3068     bnez_t1
   3069     li_a0 %1 %0
   3070     ret
   3071 :aib_zero
   3072     li_a0 %0 %0
   3073     ret
   3074 
   3075 ## copy_arg_tokens_to_pool(a0=arg_start, a1=arg_end) -> void (fatal if empty)
   3076 ## Non-leaf (calls copy_span_to_pool). Empty arg is an error.
   3077 ## If the span is wrapped in a matching outer { ... } pair, strip the outer
   3078 ## braces before copying; an empty inner span is a no-op.
   3079 :copy_arg_tokens_to_pool
   3080     enter_16
   3081     # if (arg_start == arg_end) fatal
   3082     la_br &err_bad_macro_header
   3083     beq_a0,a1
   3084     # spill a0/a1 so arg_is_braced can clobber regs
   3085     st_a0,sp,0
   3086     st_a1,sp,8
   3087     la_br &arg_is_braced
   3088     call
   3089     la_br &catp_plain
   3090     beqz_a0
   3091     # braced: strip outer braces (start+24, end-24)
   3092     ld_a0,sp,0
   3093     ld_a1,sp,8
   3094     addi_a0,a0,32
   3095     addi_a1,a1,neg32
   3096     la_br &catp_done
   3097     beq_a0,a1
   3098     la_br &copy_span_to_pool
   3099     call
   3100     la_br &catp_done
   3101     b
   3102 :catp_plain
   3103     ld_a0,sp,0
   3104     ld_a1,sp,8
   3105     la_br &copy_span_to_pool
   3106     call
   3107 :catp_done
   3108     eret
   3109 
   3110 ## copy_paste_arg_to_pool(a0=arg_start, a1=arg_end) -> void (fatal unless len 1)
   3111 ## Enforces the single-token-argument rule for params adjacent to ##.
   3112 ## Braced args are rejected — pasting onto a block is nonsense.
   3113 :copy_paste_arg_to_pool
   3114     enter_16
   3115     # spill a0/a1 for the arg_is_braced call
   3116     st_a0,sp,0
   3117     st_a1,sp,8
   3118     la_br &arg_is_braced
   3119     call
   3120     la_br &err_bad_macro_header
   3121     bnez_a0
   3122     ld_a0,sp,0
   3123     ld_a1,sp,8
   3124     # if ((arg_end - arg_start) != 24) fatal
   3125     sub_a2,a1,a0
   3126     li_a3 M1PP_TOK_SIZE
   3127     la_br &err_bad_macro_header
   3128     bne_a2,a3
   3129     la_br &copy_span_to_pool
   3130     call
   3131     eret
   3132 
   3133 ## expand_macro_tokens(a0=call_tok, a1=limit, a2=macro_ptr) -> void (fatal on bad)
   3134 ## Requires call_tok+1 is TOK_LPAREN. Runs parse_args(call_tok+1, limit),
   3135 ## verifies arg_count == macro->param_count, walks macro body, substituting
   3136 ## each param token via copy_arg_tokens_to_pool (or copy_paste_arg_to_pool
   3137 ## when adjacent to ##), copying other body tokens as-is, then runs
   3138 ## paste_pool_range over the newly-written slice.
   3139 ##
   3140 ## Outputs via globals (callers must snapshot before any nested call that
   3141 ## could overwrite them):
   3142 ##   emt_after_pos = token one past the matching ')' (= call_end_pos)
   3143 ##   emt_mark      = pool_used as of entry (start of expansion slice)
   3144 ##
   3145 :expand_macro_tokens
   3146     enter_0
   3147 
   3148     # Snapshot inputs into BSS (find_param/copy_*/paste_pool_range clobber regs).
   3149     la_a3 &emt_call_tok
   3150     st_a0,a3,0
   3151     la_a3 &emt_limit
   3152     st_a1,a3,0
   3153     la_a3 &emt_macro
   3154     st_a2,a3,0
   3155 
   3156     # lparen = call_tok + 24
   3157     addi_a0,a0,32
   3158 
   3159     # Branch split for paren-less 0-arg calls:
   3160     #   if lparen < limit AND lparen->kind == TOK_LPAREN: parse_args as usual.
   3161     #   else if macro->param_count == 0: synthesize empty arg list, no parse_args.
   3162     #   else: fatal "bad macro call".
   3163 
   3164     # if (lparen >= limit) goto emt_try_zero_arg
   3165     la_br &emt_try_zero_arg
   3166     beq_a0,a1
   3167     la_br &emt_try_zero_arg
   3168     blt_a1,a0
   3169 
   3170     # if (lparen->kind != TOK_LPAREN) goto emt_try_zero_arg
   3171     ld_a2,a0,0
   3172     li_a3 TOK_LPAREN
   3173     la_br &emt_try_zero_arg
   3174     bne_a2,a3
   3175 
   3176     # if (!lparen->tight) goto emt_try_zero_arg — `%FOO ( ... )` with a space
   3177     # is a paren-less zero-arg call followed by a literal `(`.
   3178     ld_a2,a0,24
   3179     la_br &emt_try_zero_arg
   3180     beqz_a2
   3181 
   3182     # parse_args(lparen, limit)
   3183     # a0 already lparen; a1 already limit
   3184     la_br &parse_args
   3185     call
   3186 
   3187     # Snapshot args_have_paste -> emt_saw_arg_paste BEFORE the body loop
   3188     # potentially runs nested expansions that would clobber the global. This
   3189     # snapshot is OR'd with macro->has_paste at emt_done to decide whether
   3190     # to run paste_pool_range.
   3191     la_a0 &args_have_paste
   3192     ld_t0,a0,0
   3193     la_a1 &emt_saw_arg_paste
   3194     st_t0,a1,0
   3195 
   3196     # Check arg_count == macro->param_count
   3197     la_a0 &arg_count
   3198     ld_t0,a0,0
   3199     la_a0 &emt_macro
   3200     ld_t1,a0,0
   3201     ld_t1,t1,16
   3202     la_br &err_bad_macro_header
   3203     bne_t0,t1
   3204 
   3205     # expansion_id = ++next_expansion_id (monotonic; used by local-label
   3206     # rewriting in the body-copy path to rename :@name / &@name tokens).
   3207     la_a0 &next_expansion_id
   3208     ld_t0,a0,0
   3209     addi_t0,t0,1
   3210     st_t0,a0,0
   3211     la_a1 &emt_expansion_id
   3212     st_t0,a1,0
   3213 
   3214     # Snapshot call_end_pos -> emt_after_pos before the body walk, so
   3215     # nothing in the substitution loop can clobber the resume position.
   3216     la_a0 &call_end_pos
   3217     ld_t0,a0,0
   3218     la_a1 &emt_after_pos
   3219     st_t0,a1,0
   3220     la_br &emt_after_arg_setup
   3221     b
   3222 
   3223 :emt_try_zero_arg
   3224     # No trailing LPAREN. Allowed only if macro->param_count == 0.
   3225     la_a0 &emt_macro
   3226     ld_t1,a0,0
   3227     ld_t1,t1,16
   3228     la_br &err_bad_macro_header
   3229     bnez_t1
   3230 
   3231     # No parse_args ran in this branch; args_have_paste from a stale earlier
   3232     # call MUST NOT leak into emt_saw_arg_paste. Force it to 0 so emt_done's
   3233     # paste-gate uses only macro->has_paste here.
   3234     li_t0 %0 %0
   3235     la_a1 &emt_saw_arg_paste
   3236     st_t0,a1,0
   3237 
   3238     # arg_count = 0
   3239     la_a0 &arg_count
   3240     li_t0 %0 %0
   3241     st_t0,a0,0
   3242 
   3243     # emt_after_pos = call_tok + 24
   3244     la_a0 &emt_call_tok
   3245     ld_t0,a0,0
   3246     addi_t0,t0,32
   3247     la_a1 &emt_after_pos
   3248     st_t0,a1,0
   3249 
   3250 :emt_after_arg_setup
   3251 
   3252     # mark = pool_used; emt_mark = mark
   3253     la_a0 &pool_used
   3254     ld_t0,a0,0
   3255     la_a1 &emt_mark
   3256     st_t0,a1,0
   3257 
   3258     # body_pos = macro->body_start; body_end = macro->body_end
   3259     la_a0 &emt_macro
   3260     ld_t1,a0,0
   3261     li_a2 M1PP_MACRO_BODY_START_OFF
   3262     add_a3,t1,a2
   3263     ld_a3,a3,0
   3264     la_a0 &emt_body_pos
   3265     st_a3,a0,0
   3266     la_a0 &emt_body_start
   3267     st_a3,a0,0
   3268     li_a2 M1PP_MACRO_BODY_END_OFF
   3269     add_a3,t1,a2
   3270     ld_a3,a3,0
   3271     la_a0 &emt_body_end
   3272     st_a3,a0,0
   3273 
   3274 :emt_loop
   3275     # if (body_pos == body_end) break
   3276     la_a0 &emt_body_pos
   3277     ld_t0,a0,0
   3278     la_a1 &emt_body_end
   3279     ld_t1,a1,0
   3280     la_br &emt_done
   3281     beq_t0,t1
   3282 
   3283     # Cached param_idx = macro_body_param_idx[(body_pos - macro_body_tokens) / 32].
   3284     # Set at %macro define time so the body loop never has to call find_param.
   3285     la_a1 &macro_body_tokens_ptr
   3286     ld_a1,a1,0
   3287     sub_a0,t0,a1
   3288     shri_a0,a0,5
   3289     la_a1 &macro_body_param_idx_ptr
   3290     ld_a1,a1,0
   3291     add_a1,a1,a0
   3292     lb_a0,a1,0
   3293     # Spill for emt_do_substitute_paste / _plain (no need to re-derive).
   3294     la_a1 &emt_cached_param_idx
   3295     st_a0,a1,0
   3296 
   3297     # if (param_idx == 0) body-native token: check for local-label rewrite,
   3298     # else fall through to substitute logic.
   3299     la_br &emt_check_local_label
   3300     beqz_a0
   3301 
   3302     # param_idx != 0: substitute. The emt_do_substitute_* paths read
   3303     # emt_cached_param_idx (no re-call to find_param).
   3304 
   3305     # Reload body_pos for the pasted-classification loads.
   3306     la_a0 &emt_body_pos
   3307     ld_t0,a0,0
   3308 
   3309     # Compute pasted = (body_pos > body_start AND (body_pos - 24)->kind == TOK_PASTE)
   3310     #                  OR (body_pos + 24 < body_end AND (body_pos + 24)->kind == TOK_PASTE)
   3311     la_a1 &emt_body_start
   3312     ld_t1,a1,0
   3313 
   3314     # Branch to emt_check_after if body_pos == body_start
   3315     la_br &emt_check_after
   3316     beq_t0,t1
   3317 
   3318     # prev_kind = (body_pos - 24)->kind
   3319     addi_t2,t0,neg32
   3320     ld_a2,t2,0
   3321     li_a3 TOK_PASTE
   3322     la_br &emt_pasted
   3323     beq_a2,a3
   3324 
   3325 :emt_check_after
   3326     # next_pos = body_pos + 24; if (next_pos >= body_end) skip
   3327     addi_t2,t0,32
   3328     la_a1 &emt_body_end
   3329     ld_a3,a1,0
   3330     # if (next_pos == body_end) -> not pasted (need next_pos < body_end)
   3331     la_br &emt_not_pasted
   3332     beq_t2,a3
   3333     # next_kind = next_pos->kind
   3334     ld_a2,t2,0
   3335     li_a3 TOK_PASTE
   3336     la_br &emt_pasted
   3337     beq_a2,a3
   3338     la_br &emt_not_pasted
   3339     b
   3340 
   3341 :emt_pasted
   3342     # body_pos is a param adjacent to ##: substitute one-token arg.
   3343     la_br &emt_do_substitute_paste
   3344     b
   3345 
   3346 :emt_not_pasted
   3347     # body_pos is a param NOT adjacent to ##: substitute arg span.
   3348     la_br &emt_do_substitute_plain
   3349     b
   3350 
   3351 ## emt_check_local_label: read the cached macro_body_is_local_label[]
   3352 ## flag (set at %macro define time). 0 -> emt_copy_literal copies the
   3353 ## body token verbatim; 1 -> falls through to emt_rewrite_local_label.
   3354 ## Replaces the per-expansion ':@' / '&@' / '@' predicate that used to
   3355 ## live inline here.
   3356 :emt_check_local_label
   3357     la_a0 &emt_body_pos
   3358     ld_t0,a0,0
   3359     la_a1 &macro_body_tokens_ptr
   3360     ld_a1,a1,0
   3361     sub_a0,t0,a1
   3362     shri_a0,a0,5
   3363     la_a1 &macro_body_is_local_label_ptr
   3364     ld_a1,a1,0
   3365     add_a1,a1,a0
   3366     lb_a0,a1,0
   3367     la_br &emt_copy_literal
   3368     beqz_a0
   3369     # Cached flag is 1: fall through to rewrite.
   3370 
   3371 ## emt_rewrite_local_label: build "sigil + tail + __ + decimal(NN)" in
   3372 ## local_label_scratch, stash it into text_buf via append_text, and push
   3373 ## a TOK_WORD to expand_pool.
   3374 :emt_rewrite_local_label
   3375     # Stash body_tok text_ptr / text_len into BSS so they survive
   3376     # function calls (append_text is non-leaf via its arena bump).
   3377     la_a0 &emt_body_pos
   3378     ld_t0,a0,0
   3379     ld_a1,t0,8
   3380     la_a2 &ll_src_ptr
   3381     st_a1,a2,0
   3382     ld_a1,t0,16
   3383     la_a2 &ll_src_len
   3384     st_a1,a2,0
   3385 
   3386     # --- Convert emt_expansion_id to decimal, reverse-fill into
   3387     # --- local_label_digits[0..24). Write right-to-left starting at
   3388     # --- offset 23 so digits are adjacent at [cursor, &scratch+24).
   3389     la_a0 &emt_expansion_id
   3390     ld_t0,a0,0                 # t0 = id (mutated)
   3391     la_t1 &local_label_digits
   3392     li_a2 %24 %0
   3393     add_t1,t1,a2               # t1 = end (one past last slot)
   3394     mov_t2,t1                  # t2 = cursor (moves left)
   3395 
   3396     # Special-case id == 0 -> single '0' digit.
   3397     la_br &emt_rldg_loop
   3398     bnez_t0
   3399     addi_t2,t2,neg1
   3400     li_a0 %48 %0
   3401     sb_a0,t2,0
   3402     la_br &emt_rldg_done
   3403     b
   3404 :emt_rldg_loop
   3405     la_br &emt_rldg_done
   3406     beqz_t0
   3407     # digit = id % 10
   3408     mov_a0,t0
   3409     li_a1 %10 %0
   3410     rem_a2,a0,a1               # a2 = id % 10
   3411     addi_a2,a2,48              # a2 = '0' + digit
   3412     addi_t2,t2,neg1
   3413     sb_a2,t2,0                 # *--cursor = digit
   3414     # id = id / 10
   3415     mov_a0,t0
   3416     li_a1 %10 %0
   3417     div_a0,a0,a1
   3418     mov_t0,a0
   3419     la_br &emt_rldg_loop
   3420     b
   3421 :emt_rldg_done
   3422     # digit_count = end - cursor
   3423     la_a1 &local_label_digits
   3424     li_a2 %24 %0
   3425     add_a1,a1,a2               # a1 = end
   3426     sub_a0,a1,t2               # a0 = digit_count
   3427     la_a1 &ll_digit_count
   3428     st_a0,a1,0
   3429     # Save cursor (start of digits) for the copy step.
   3430     la_a1 &ll_digit_cursor
   3431     st_t2,a1,0
   3432 
   3433     # --- Build final text in local_label_scratch ---
   3434     # Layout: [0]=sigil, [1..1+tail_len)=tail, then "__", then digits.
   3435     # tail_len = len - 2
   3436 
   3437     # Write sigil (src_ptr[0]) to scratch[0].
   3438     la_a0 &ll_src_ptr
   3439     ld_a1,a0,0
   3440     lb_a2,a1,0
   3441     la_a3 &local_label_scratch_ptr
   3442     ld_a3,a3,0
   3443     sb_a2,a3,0
   3444 
   3445     # Copy tail: scratch[1..1+tail_len) <- src_ptr[2..2+tail_len).
   3446     la_a0 &ll_src_len
   3447     ld_a1,a0,0
   3448     li_a2 %2 %0
   3449     sub_t0,a1,a2               # t0 = tail_len = src_len - 2
   3450     la_a0 &ll_src_ptr
   3451     ld_a1,a0,0                 # a1 = src_ptr
   3452     addi_a1,a1,2               # a1 = src_ptr + 2 (tail start)
   3453     la_a2 &local_label_scratch_ptr
   3454     ld_a2,a2,0
   3455     addi_a2,a2,1               # a2 = scratch + 1 (dst tail start)
   3456     li_t1 %0 %0                # t1 = i
   3457 :emt_rlbuild_tail_loop
   3458     la_br &emt_rlbuild_tail_done
   3459     beq_t1,t0
   3460     add_a3,a1,t1
   3461     lb_a3,a3,0
   3462     add_t2,a2,t1
   3463     sb_a3,t2,0
   3464     addi_t1,t1,1
   3465     la_br &emt_rlbuild_tail_loop
   3466     b
   3467 :emt_rlbuild_tail_done
   3468     # Save tail_len for later offset math.
   3469     la_a0 &ll_tail_len
   3470     st_t0,a0,0
   3471 
   3472     # Write "__" at scratch[1+tail_len], scratch[2+tail_len].
   3473     la_a2 &local_label_scratch_ptr
   3474     ld_a2,a2,0
   3475     addi_a2,a2,1
   3476     add_a2,a2,t0               # a2 = &scratch[1+tail_len]
   3477     li_a3 %95 %0               # '_'
   3478     sb_a3,a2,0
   3479     addi_a2,a2,1
   3480     sb_a3,a2,0
   3481 
   3482     # Copy digits: scratch[3+tail_len..3+tail_len+digit_count) <- digit_cursor[0..digit_count).
   3483     la_a0 &ll_digit_count
   3484     ld_t1,a0,0                 # t1 = digit_count
   3485     la_a0 &ll_digit_cursor
   3486     ld_a1,a0,0                 # a1 = digit_cursor (src)
   3487     la_a0 &ll_tail_len
   3488     ld_t0,a0,0                 # t0 = tail_len
   3489     la_a2 &local_label_scratch_ptr
   3490     ld_a2,a2,0
   3491     addi_a2,a2,3
   3492     add_a2,a2,t0               # a2 = &scratch[3+tail_len] (dst)
   3493     li_t2 %0 %0                # t2 = i
   3494 :emt_rlbuild_digits_loop
   3495     la_br &emt_rlbuild_digits_done
   3496     beq_t2,t1
   3497     add_a3,a1,t2
   3498     lb_a3,a3,0
   3499     add_a0,a2,t2
   3500     sb_a3,a0,0
   3501     addi_t2,t2,1
   3502     la_br &emt_rlbuild_digits_loop
   3503     b
   3504 :emt_rlbuild_digits_done
   3505 
   3506     # total_len = 1 + tail_len + 2 + digit_count = 3 + tail_len + digit_count
   3507     la_a0 &ll_tail_len
   3508     ld_a1,a0,0
   3509     la_a0 &ll_digit_count
   3510     ld_a2,a0,0
   3511     add_a1,a1,a2
   3512     addi_a1,a1,3
   3513     la_a0 &ll_total_len
   3514     st_a1,a0,0
   3515 
   3516     # durable_ptr = append_text(&local_label_scratch, total_len)
   3517     la_a0 &local_label_scratch_ptr
   3518     ld_a0,a0,0
   3519     la_br &append_text
   3520     call
   3521     # a0 = durable_ptr (into text_buf)
   3522 
   3523     # Push TOK_WORD { kind=0, text_ptr=durable_ptr, text_len=total_len } to expand_pool.
   3524     la_a1 &pool_used
   3525     ld_t0,a1,0
   3526     li_a2 M1PP_EXPAND_CAP
   3527     la_br &err_token_overflow
   3528     beq_t0,a2
   3529     la_a3 &expand_pool_ptr
   3530     ld_a3,a3,0
   3531     add_a3,a3,t0               # a3 = dst slot
   3532     # kind = TOK_WORD
   3533     li_a2 TOK_WORD
   3534     st_a2,a3,0
   3535     # text_ptr
   3536     st_a0,a3,8
   3537     # text_len
   3538     la_a0 &ll_total_len
   3539     ld_a2,a0,0
   3540     st_a2,a3,16
   3541     # tight = 0 (synthetic local-label token)
   3542     li_a2 %0 %0
   3543     st_a2,a3,24
   3544     # pool_used += 32
   3545     addi_t0,t0,32
   3546     la_a1 &pool_used
   3547     st_t0,a1,0
   3548 
   3549     # body_pos += 32
   3550     la_a0 &emt_body_pos
   3551     ld_t0,a0,0
   3552     addi_t0,t0,32
   3553     st_t0,a0,0
   3554     la_br &emt_loop
   3555     b
   3556 
   3557 :emt_copy_literal
   3558     # Append *body_pos to expand_pool. Check overflow.
   3559     la_a0 &pool_used
   3560     ld_t0,a0,0
   3561     li_a1 M1PP_EXPAND_CAP
   3562     la_br &err_token_overflow
   3563     beq_t0,a1
   3564     # dst = &expand_pool + pool_used
   3565     la_a2 &expand_pool_ptr
   3566     ld_a2,a2,0
   3567     add_a2,a2,t0
   3568     # src = body_pos
   3569     la_a0 &emt_body_pos
   3570     ld_a3,a0,0
   3571     # copy 32 bytes (4 x 8) — preserves tight at +24
   3572     ld_a0,a3,0
   3573     st_a0,a2,0
   3574     ld_a0,a3,8
   3575     st_a0,a2,8
   3576     ld_a0,a3,16
   3577     st_a0,a2,16
   3578     ld_a0,a3,24
   3579     st_a0,a2,24
   3580     # pool_used += 32
   3581     addi_t0,t0,32
   3582     la_a0 &pool_used
   3583     st_t0,a0,0
   3584     # body_pos += 32
   3585     addi_a3,a3,32
   3586     la_a0 &emt_body_pos
   3587     st_a3,a0,0
   3588     la_br &emt_loop
   3589     b
   3590 
   3591 :emt_do_substitute_paste
   3592     # Use the cached param_idx (set at the top of emt_loop) instead of
   3593     # re-running find_param.
   3594     la_a0 &emt_cached_param_idx
   3595     ld_a0,a0,0
   3596     addi_a0,a0,neg1
   3597     shli_a0,a0,3
   3598     la_a1 &arg_starts_ptr
   3599     ld_a1,a1,0
   3600     add_a1,a1,a0
   3601     ld_t0,a1,0
   3602     la_a1 &arg_ends_ptr
   3603     ld_a1,a1,0
   3604     add_a1,a1,a0
   3605     ld_t1,a1,0
   3606     mov_a0,t0
   3607     mov_a1,t1
   3608     la_br &copy_paste_arg_to_pool
   3609     call
   3610     # body_pos += 24
   3611     la_a0 &emt_body_pos
   3612     ld_t0,a0,0
   3613     addi_t0,t0,32
   3614     st_t0,a0,0
   3615     la_br &emt_loop
   3616     b
   3617 
   3618 :emt_do_substitute_plain
   3619     # Use the cached param_idx (set at the top of emt_loop) instead of
   3620     # re-running find_param.
   3621     la_a0 &emt_cached_param_idx
   3622     ld_a0,a0,0
   3623     addi_a0,a0,neg1
   3624     shli_a0,a0,3
   3625     la_a1 &arg_starts_ptr
   3626     ld_a1,a1,0
   3627     add_a1,a1,a0
   3628     ld_t0,a1,0
   3629     la_a1 &arg_ends_ptr
   3630     ld_a1,a1,0
   3631     add_a1,a1,a0
   3632     ld_t1,a1,0
   3633     mov_a0,t0
   3634     mov_a1,t1
   3635     la_br &copy_arg_tokens_to_pool
   3636     call
   3637     # body_pos += 24
   3638     la_a0 &emt_body_pos
   3639     ld_t0,a0,0
   3640     addi_t0,t0,32
   3641     st_t0,a0,0
   3642     la_br &emt_loop
   3643     b
   3644 
   3645 :emt_done
   3646     # Gate paste_pool_range(mark) on (macro->has_paste OR emt_saw_arg_paste).
   3647     # When neither side contains TOK_PASTE the pool sweep is wasted work — the
   3648     # whole point of has_paste / args_have_paste is to skip it for the common
   3649     # case of a `##`-free expansion.
   3650     la_a0 &emt_macro
   3651     ld_t0,a0,0
   3652     li_a1 M1PP_MACRO_HAS_PASTE_OFF
   3653     add_t0,t0,a1
   3654     ld_t0,t0,0
   3655     la_a0 &emt_saw_arg_paste
   3656     ld_t1,a0,0
   3657     or_t0,t0,t1
   3658     la_br &emt_done_skip_paste
   3659     beqz_t0
   3660     la_a0 &emt_mark
   3661     ld_a0,a0,0
   3662     la_br &paste_pool_range
   3663     call
   3664 
   3665 :emt_done_skip_paste
   3666     eret
   3667 
   3668 ## expand_call(a0=stream_ptr, a1=macro_ptr) -> void (fatal on bad call)
   3669 ## Calls expand_macro_tokens for the call at stream->pos, sets
   3670 ## stream->pos = emt_after_pos, stream->line_start = 0, and
   3671 ## push_pool_stream_from_mark(emt_mark) to rescan the expansion.
   3672 :expand_call
   3673     enter_8
   3674 
   3675     # spill stream_ptr to local frame slot (sp+16 is the first local; sp+0/+8
   3676     # hold the saved return address and saved caller sp).
   3677     st_a0,sp,0
   3678 
   3679     # expand_macro_tokens(stream->pos, stream->end, macro)
   3680     # stream->pos at +16, stream->end at +8
   3681     ld_t0,a0,16
   3682     ld_t1,a0,8
   3683     mov_a2,a1
   3684     mov_a0,t0
   3685     mov_a1,t1
   3686     la_br &expand_macro_tokens
   3687     call
   3688 
   3689     # stream->pos = emt_after_pos
   3690     ld_a0,sp,0
   3691     la_a1 &emt_after_pos
   3692     ld_t0,a1,0
   3693     st_t0,a0,16
   3694 
   3695     # stream->line_start = 0
   3696     li_t0 %0 %0
   3697     st_t0,a0,24
   3698 
   3699     # push_pool_stream_from_mark(emt_mark)
   3700     la_a0 &emt_mark
   3701     ld_a0,a0,0
   3702     la_br &push_pool_stream_from_mark
   3703     call
   3704 
   3705     eret
   3706 
   3707 ## ============================================================================
   3708 ## --- ## token paste compaction ----------------------------------------------
   3709 ## ============================================================================
   3710 
   3711 ## append_pasted_token(a0=dst_tok, a1=left_tok, a2=right_tok) -> void (fatal)
   3712 ## Concatenate left->text and right->text into paste_scratch, then call
   3713 ## append_text(&paste_scratch, total_len) for stable storage in text_buf,
   3714 ## and write *dst = { TOK_WORD, text_ptr, total_len }. paste_scratch is
   3715 ## 256 bytes (M0's quoted-literal cap). Fatal err_text_overflow if combined
   3716 ## length exceeds 256 bytes; append_text handles its own text_buf overflow.
   3717 :append_pasted_token
   3718     enter_0
   3719 
   3720     # ---- Spill all three operands to BSS so we can survive append_text. ----
   3721     la_t0 &paste_dst_save
   3722     st_a0,t0,0
   3723     la_t0 &paste_left_ptr
   3724     ld_t1,a1,8
   3725     st_t1,t0,0
   3726     la_t0 &paste_left_len
   3727     ld_t1,a1,16
   3728     st_t1,t0,0
   3729     la_t0 &paste_right_ptr
   3730     ld_t1,a2,8
   3731     st_t1,t0,0
   3732     la_t0 &paste_right_len
   3733     ld_t1,a2,16
   3734     st_t1,t0,0
   3735 
   3736     # ---- total_len = left.len + right.len; fatal if > 256 ----
   3737     la_t0 &paste_left_len
   3738     ld_t1,t0,0
   3739     la_t0 &paste_right_len
   3740     ld_t2,t0,0
   3741     add_a0,t1,t2
   3742     li_a1 %256 %0
   3743     la_br &err_text_overflow
   3744     blt_a1,a0
   3745     # save total_len for the append_text call below
   3746     la_t0 &paste_total_len
   3747     st_a0,t0,0
   3748 
   3749     # ---- Copy left bytes: paste_scratch[0..left.len) <- left.text_ptr ----
   3750     la_t0 &paste_left_ptr
   3751     ld_t0,t0,0
   3752     la_t1 &paste_left_len
   3753     ld_t1,t1,0
   3754     la_t2 &paste_scratch_ptr
   3755     ld_t2,t2,0
   3756     li_a0 %0 %0
   3757 :append_pasted_left_loop
   3758     la_br &append_pasted_left_done
   3759     beq_a0,t1
   3760     add_a1,t0,a0
   3761     lb_a1,a1,0
   3762     add_a2,t2,a0
   3763     sb_a1,a2,0
   3764     addi_a0,a0,1
   3765     la_br &append_pasted_left_loop
   3766     b
   3767 :append_pasted_left_done
   3768 
   3769     # ---- Copy right bytes: paste_scratch[left.len..total_len) <- right.text_ptr ----
   3770     la_t0 &paste_right_ptr
   3771     ld_t0,t0,0
   3772     la_t1 &paste_right_len
   3773     ld_t1,t1,0
   3774     la_t2 &paste_scratch_ptr
   3775     ld_t2,t2,0
   3776     la_a3 &paste_left_len
   3777     ld_a3,a3,0
   3778     add_t2,t2,a3              # t2 = &paste_scratch[left.len]
   3779     li_a0 %0 %0
   3780 :append_pasted_right_loop
   3781     la_br &append_pasted_right_done
   3782     beq_a0,t1
   3783     add_a1,t0,a0
   3784     lb_a1,a1,0
   3785     add_a2,t2,a0
   3786     sb_a1,a2,0
   3787     addi_a0,a0,1
   3788     la_br &append_pasted_right_loop
   3789     b
   3790 :append_pasted_right_done
   3791 
   3792     # ---- text_ptr = append_text(&paste_scratch, total_len) ----
   3793     la_a0 &paste_scratch_ptr
   3794     ld_a0,a0,0
   3795     la_a1 &paste_total_len
   3796     ld_a1,a1,0
   3797     la_br &append_text
   3798     call
   3799     # a0 = text_ptr (returned)
   3800 
   3801     # ---- *dst = { TOK_WORD, text_ptr, total_len, tight=0 } ----
   3802     la_t0 &paste_dst_save
   3803     ld_t0,t0,0
   3804     li_a2 TOK_WORD
   3805     st_a2,t0,0
   3806     st_a0,t0,8
   3807     la_a1 &paste_total_len
   3808     ld_a1,a1,0
   3809     st_a1,t0,16
   3810     li_a1 %0 %0
   3811     st_a1,t0,24
   3812 
   3813     eret
   3814 
   3815 ## paste_pool_range(a0=mark) -> void (fatal on bad paste)
   3816 ## In-place compactor over expand_pool[mark..pool_used). For each TOK_PASTE,
   3817 ## walk back from `out` over already-copied NEWLINE tokens to find the left
   3818 ## operand, walk forward from `in+1` over NEWLINE tokens to find the right
   3819 ## operand, then paste left+right into the left slot. The discarded newlines
   3820 ## on either side are dropped. Copy other tokens forward. Update pool_used
   3821 ## to the new end. Fatal if ## has no operand on a side, or its operand is
   3822 ## itself PASTE.
   3823 :paste_pool_range
   3824     enter_0
   3825 
   3826     # ---- start = expand_pool + mark ----
   3827     la_t0 &expand_pool_ptr
   3828     ld_t0,t0,0
   3829     add_t0,t0,a0
   3830     la_t1 &paste_start
   3831     st_t0,t1,0
   3832     # paste_in = start
   3833     la_t1 &paste_in
   3834     st_t0,t1,0
   3835     # paste_out = start
   3836     la_t1 &paste_out
   3837     st_t0,t1,0
   3838 
   3839     # ---- end = expand_pool + pool_used ----
   3840     la_t1 &pool_used
   3841     ld_t2,t1,0
   3842     la_t1 &expand_pool_ptr
   3843     ld_t1,t1,0
   3844     add_t2,t1,t2
   3845     la_t1 &paste_end
   3846     st_t2,t1,0
   3847 
   3848 :paste_pool_loop
   3849     # in = paste_in; end = paste_end; if (in == end) done
   3850     la_a0 &paste_in
   3851     ld_t0,a0,0
   3852     la_a1 &paste_end
   3853     ld_t1,a1,0
   3854     la_br &paste_pool_done
   3855     beq_t0,t1
   3856 
   3857     # kind = in->kind
   3858     ld_a2,t0,0
   3859     li_a3 TOK_PASTE
   3860     la_br &paste_pool_handle_paste
   3861     beq_a2,a3
   3862 
   3863     # ---- non-PASTE: copy *in to *out, advance both by 32 ----
   3864     la_a0 &paste_out
   3865     ld_t2,a0,0
   3866     # if (in == out) skip the copy
   3867     la_br &paste_pool_skip_copy
   3868     beq_t0,t2
   3869     ld_a3,t0,0
   3870     st_a3,t2,0
   3871     ld_a3,t0,8
   3872     st_a3,t2,8
   3873     ld_a3,t0,16
   3874     st_a3,t2,16
   3875     ld_a3,t0,24
   3876     st_a3,t2,24
   3877 :paste_pool_skip_copy
   3878     addi_t0,t0,32
   3879     addi_t2,t2,32
   3880     la_a0 &paste_in
   3881     st_t0,a0,0
   3882     la_a0 &paste_out
   3883     st_t2,a0,0
   3884     la_br &paste_pool_loop
   3885     b
   3886 
   3887 :paste_pool_handle_paste
   3888     # ---- TOK_PASTE handling ----
   3889     # Find left operand: start at out, walk back over NEWLINE tokens, then
   3890     # step one more to land on the actual left operand. If we cannot step
   3891     # back past start, the ## has no left operand -> fatal.
   3892     la_a0 &paste_out
   3893     ld_t1,a0,0                   # t1 = left (initially = out)
   3894     la_a1 &paste_start
   3895     ld_t2,a1,0                   # t2 = start
   3896 :paste_pool_left_skip_nl
   3897     la_br &paste_pool_left_step
   3898     beq_t1,t2
   3899     ld_a2,t1,neg32
   3900     li_a3 TOK_NEWLINE
   3901     la_br &paste_pool_left_step
   3902     bne_a2,a3
   3903     addi_t1,t1,neg32
   3904     la_br &paste_pool_left_skip_nl
   3905     b
   3906 :paste_pool_left_step
   3907     # left == start? then ## is first (fatal).
   3908     la_br &err_bad_macro_header
   3909     beq_t1,t2
   3910     addi_t1,t1,neg32             # left now points at the operand
   3911     # Validate left->kind != TOK_PASTE.
   3912     ld_a2,t1,0
   3913     li_a3 TOK_PASTE
   3914     la_br &err_bad_macro_header
   3915     beq_a2,a3
   3916 
   3917     # Find right operand: start at in+1, walk forward over NEWLINE tokens.
   3918     # If we run out of tokens, ## is last (fatal). If right is PASTE, fatal.
   3919     addi_t0,t0,32                # t0 = right (initially = in + 1)
   3920     la_a1 &paste_end
   3921     ld_a2,a1,0                   # a2 = end
   3922 :paste_pool_right_skip_nl
   3923     la_br &err_bad_macro_header
   3924     beq_t0,a2
   3925     ld_a3,t0,0
   3926     li_a1 TOK_NEWLINE
   3927     la_br &paste_pool_right_step
   3928     bne_a3,a1
   3929     addi_t0,t0,32
   3930     la_br &paste_pool_right_skip_nl
   3931     b
   3932 :paste_pool_right_step
   3933     # Validate right->kind != TOK_PASTE.
   3934     li_a1 TOK_PASTE
   3935     la_br &err_bad_macro_header
   3936     beq_a3,a1
   3937 
   3938     # Pre-publish out = left + 1 and in = right + 1, since the call below
   3939     # clobbers the t* / a* registers we used to track them.
   3940     addi_a3,t1,32
   3941     la_a1 &paste_out
   3942     st_a3,a1,0
   3943     addi_a3,t0,32
   3944     la_a1 &paste_in
   3945     st_a3,a1,0
   3946 
   3947     # append_pasted_token(left, left, right)
   3948     mov_a0,t1
   3949     mov_a1,t1
   3950     mov_a2,t0
   3951     la_br &append_pasted_token
   3952     call
   3953 
   3954     la_br &paste_pool_loop
   3955     b
   3956 
   3957 :paste_pool_done
   3958     # pool_used = (out - expand_pool)
   3959     la_a0 &paste_out
   3960     ld_t0,a0,0
   3961     la_a1 &expand_pool_ptr
   3962     ld_a1,a1,0
   3963     sub_t0,t0,a1
   3964     la_a1 &pool_used
   3965     st_t0,a1,0
   3966     eret
   3967 
   3968 ## ============================================================================
   3969 ## --- Integer atoms + S-expression evaluator ---------------------------------
   3970 ## ============================================================================
   3971 
   3972 ## parse_int_token(a0=tok) -> a0 = i64 (fatal on bad). Leaf.
   3973 ## Accepts decimal (optional leading '-') and 0x-prefixed hex. Positive
   3974 ## values are accumulated as u64 and reinterpreted as i64, so values with
   3975 ## the high bit set wrap to negative i64.
   3976 ##
   3977 ## Register usage (leaf, no calls):
   3978 ##   t0 = src ptr (cursor into text)
   3979 ##   t1 = end ptr (text + len)
   3980 ##   t2 = current byte
   3981 ##   a0 = accumulator (return)
   3982 ##   a1 = negative flag (0/1)
   3983 ##   a2 = scratch (digit, multiplier)
   3984 ##   a3 = scratch (compare value)
   3985 :parse_int_token
   3986     # if (tok->kind != TOK_WORD) fatal
   3987     ld_t0,a0,0
   3988     li_t1 TOK_WORD
   3989     la_br &err_bad_macro_header
   3990     bne_t0,t1
   3991 
   3992     # src = tok->text_ptr; len = tok->text_len; end = src + len
   3993     ld_t0,a0,8
   3994     ld_t1,a0,16
   3995 
   3996     # if (len <= 0) fatal
   3997     la_br &err_bad_macro_header
   3998     beqz_t1
   3999     add_t1,t0,t1
   4000 
   4001     # negative = 0
   4002     li_a1 %0 %0
   4003 
   4004     # if (*src == '-') { negative = 1; src++; if (src == end) fatal }
   4005     lb_t2,t0,0
   4006     li_a3 %45 %0
   4007     la_br &pit_after_sign
   4008     bne_t2,a3
   4009     li_a1 %1 %0
   4010     addi_t0,t0,1
   4011     la_br &err_bad_macro_header
   4012     beq_t0,t1
   4013 
   4014 :pit_after_sign
   4015     # accumulator = 0
   4016     li_a0 %0 %0
   4017 
   4018     # check for 0x / 0X prefix: need at least 2 chars left
   4019     mov_a2,t1
   4020     sub_a2,a2,t0
   4021     li_a3 %2 %0
   4022     la_br &pit_decimal
   4023     blt_a2,a3
   4024 
   4025     # if (src[0] == '0' && (src[1] == 'x' || src[1] == 'X')) -> hex
   4026     lb_t2,t0,0
   4027     li_a3 %48 %0
   4028     la_br &pit_decimal
   4029     bne_t2,a3
   4030     addi_a2,t0,1
   4031     lb_a2,a2,0
   4032     li_a3 %120 %0
   4033     la_br &pit_hex_start
   4034     beq_a2,a3
   4035     li_a3 %88 %0
   4036     la_br &pit_hex_start
   4037     beq_a2,a3
   4038     la_br &pit_decimal
   4039     b
   4040 
   4041 :pit_hex_start
   4042     # consume "0x"; require at least one hex digit after
   4043     addi_t0,t0,2
   4044     la_br &err_bad_macro_header
   4045     beq_t0,t1
   4046 :pit_hex_loop
   4047     la_br &pit_finish
   4048     beq_t0,t1
   4049     lb_t2,t0,0
   4050 
   4051     # 0..9
   4052     li_a3 %48 %0
   4053     la_br &pit_hex_check_lower
   4054     blt_t2,a3
   4055     li_a3 %57 %0
   4056     la_br &pit_hex_check_lower
   4057     blt_a3,t2
   4058     # digit = c - '0'
   4059     addi_a2,t2,neg48
   4060     la_br &pit_hex_accum
   4061     b
   4062 
   4063 :pit_hex_check_lower
   4064     # 'a'..'f'
   4065     li_a3 %97 %0
   4066     la_br &pit_hex_check_upper
   4067     blt_t2,a3
   4068     li_a3 %102 %0
   4069     la_br &pit_hex_check_upper
   4070     blt_a3,t2
   4071     # digit = (c - 'a') + 10
   4072     li_a3 %97 %0
   4073     sub_a2,t2,a3
   4074     addi_a2,a2,8
   4075     addi_a2,a2,2
   4076     la_br &pit_hex_accum
   4077     b
   4078 
   4079 :pit_hex_check_upper
   4080     # 'A'..'F'
   4081     li_a3 %65 %0
   4082     la_br &err_bad_macro_header
   4083     blt_t2,a3
   4084     li_a3 %70 %0
   4085     la_br &err_bad_macro_header
   4086     blt_a3,t2
   4087     # digit = (c - 'A') + 10
   4088     li_a3 %65 %0
   4089     sub_a2,t2,a3
   4090     addi_a2,a2,8
   4091     addi_a2,a2,2
   4092 
   4093 :pit_hex_accum
   4094     # accum = (accum << 4) | digit
   4095     shli_a0,a0,4
   4096     or_a0,a0,a2
   4097     addi_t0,t0,1
   4098     la_br &pit_hex_loop
   4099     b
   4100 
   4101 :pit_decimal
   4102     # decimal loop: accum = accum * 10 + digit
   4103     # (caller already ensured len > 0 and that src points to first digit)
   4104 :pit_decimal_loop
   4105     la_br &pit_finish
   4106     beq_t0,t1
   4107     lb_t2,t0,0
   4108     li_a3 %48 %0
   4109     la_br &err_bad_macro_header
   4110     blt_t2,a3
   4111     li_a3 %57 %0
   4112     la_br &err_bad_macro_header
   4113     blt_a3,t2
   4114     # accum = accum * 10
   4115     li_a3 %10 %0
   4116     mul_a0,a0,a3
   4117     # digit = c - '0'; accum += digit
   4118     addi_a2,t2,neg48
   4119     add_a0,a0,a2
   4120     addi_t0,t0,1
   4121     la_br &pit_decimal_loop
   4122     b
   4123 
   4124 :pit_finish
   4125     # if (negative) accum = 0 - accum
   4126     la_br &pit_done
   4127     beqz_a1
   4128     li_a3 %0 %0
   4129     sub_a0,a3,a0
   4130 :pit_done
   4131     ret
   4132 
   4133 ## expr_op_code(a0=tok) -> a0 = EXPR_ADD..EXPR_STRLEN, or EXPR_INVALID.
   4134 ## Accepts operator tokens: +  -  *  /  %  <<  >>  &  |  ^  ~  =  !=
   4135 ## <  <=  >  >=  strlen. Non-WORD tok or unknown operator -> EXPR_INVALID.
   4136 ##
   4137 ## tok_eq_const is a leaf but clobbers a0..a3,t0..t2; spill tok to eoc_tok
   4138 ## once, reload before each compare. Needs an enter_0 frame because it
   4139 ## issues `call` instructions (aarch64 CALL writes LR).
   4140 :expr_op_code
   4141     enter_0
   4142     # spill tok; reject non-WORD up front
   4143     la_a1 &eoc_tok
   4144     st_a0,a1,0
   4145     ld_t0,a0,0
   4146     li_t1 TOK_WORD
   4147     la_br &eoc_invalid
   4148     bne_t0,t1
   4149 
   4150     # "+" -> EXPR_ADD
   4151     la_a0 &eoc_tok
   4152     ld_a0,a0,0
   4153     la_a1 &op_plus
   4154     li_a2 %1 %0
   4155     la_br &tok_eq_const
   4156     call
   4157     la_br &eoc_add
   4158     bnez_a0
   4159 
   4160     # "-" -> EXPR_SUB
   4161     la_a0 &eoc_tok
   4162     ld_a0,a0,0
   4163     la_a1 &op_minus
   4164     li_a2 %1 %0
   4165     la_br &tok_eq_const
   4166     call
   4167     la_br &eoc_sub
   4168     bnez_a0
   4169 
   4170     # "*" -> EXPR_MUL
   4171     la_a0 &eoc_tok
   4172     ld_a0,a0,0
   4173     la_a1 &op_star
   4174     li_a2 %1 %0
   4175     la_br &tok_eq_const
   4176     call
   4177     la_br &eoc_mul
   4178     bnez_a0
   4179 
   4180     # "/" -> EXPR_DIV
   4181     la_a0 &eoc_tok
   4182     ld_a0,a0,0
   4183     la_a1 &op_slash
   4184     li_a2 %1 %0
   4185     la_br &tok_eq_const
   4186     call
   4187     la_br &eoc_div
   4188     bnez_a0
   4189 
   4190     # "%" -> EXPR_MOD
   4191     la_a0 &eoc_tok
   4192     ld_a0,a0,0
   4193     la_a1 &op_percent
   4194     li_a2 %1 %0
   4195     la_br &tok_eq_const
   4196     call
   4197     la_br &eoc_mod
   4198     bnez_a0
   4199 
   4200     # "<<" -> EXPR_SHL
   4201     la_a0 &eoc_tok
   4202     ld_a0,a0,0
   4203     la_a1 &op_shl
   4204     li_a2 %2 %0
   4205     la_br &tok_eq_const
   4206     call
   4207     la_br &eoc_shl
   4208     bnez_a0
   4209 
   4210     # ">>" -> EXPR_SHR
   4211     la_a0 &eoc_tok
   4212     ld_a0,a0,0
   4213     la_a1 &op_shr
   4214     li_a2 %2 %0
   4215     la_br &tok_eq_const
   4216     call
   4217     la_br &eoc_shr
   4218     bnez_a0
   4219 
   4220     # "&" -> EXPR_AND
   4221     la_a0 &eoc_tok
   4222     ld_a0,a0,0
   4223     la_a1 &op_amp
   4224     li_a2 %1 %0
   4225     la_br &tok_eq_const
   4226     call
   4227     la_br &eoc_and
   4228     bnez_a0
   4229 
   4230     # "|" -> EXPR_OR
   4231     la_a0 &eoc_tok
   4232     ld_a0,a0,0
   4233     la_a1 &op_bar
   4234     li_a2 %1 %0
   4235     la_br &tok_eq_const
   4236     call
   4237     la_br &eoc_or
   4238     bnez_a0
   4239 
   4240     # "^" -> EXPR_XOR
   4241     la_a0 &eoc_tok
   4242     ld_a0,a0,0
   4243     la_a1 &op_caret
   4244     li_a2 %1 %0
   4245     la_br &tok_eq_const
   4246     call
   4247     la_br &eoc_xor
   4248     bnez_a0
   4249 
   4250     # "~" -> EXPR_NOT
   4251     la_a0 &eoc_tok
   4252     ld_a0,a0,0
   4253     la_a1 &op_tilde
   4254     li_a2 %1 %0
   4255     la_br &tok_eq_const
   4256     call
   4257     la_br &eoc_not
   4258     bnez_a0
   4259 
   4260     # "=" -> EXPR_EQ
   4261     la_a0 &eoc_tok
   4262     ld_a0,a0,0
   4263     la_a1 &op_eq
   4264     li_a2 %1 %0
   4265     la_br &tok_eq_const
   4266     call
   4267     la_br &eoc_eq
   4268     bnez_a0
   4269 
   4270     # "!=" -> EXPR_NE
   4271     la_a0 &eoc_tok
   4272     ld_a0,a0,0
   4273     la_a1 &op_ne
   4274     li_a2 %2 %0
   4275     la_br &tok_eq_const
   4276     call
   4277     la_br &eoc_ne
   4278     bnez_a0
   4279 
   4280     # "<=" -> EXPR_LE (check before single "<")
   4281     la_a0 &eoc_tok
   4282     ld_a0,a0,0
   4283     la_a1 &op_le
   4284     li_a2 %2 %0
   4285     la_br &tok_eq_const
   4286     call
   4287     la_br &eoc_le
   4288     bnez_a0
   4289 
   4290     # "<" -> EXPR_LT
   4291     la_a0 &eoc_tok
   4292     ld_a0,a0,0
   4293     la_a1 &op_lt
   4294     li_a2 %1 %0
   4295     la_br &tok_eq_const
   4296     call
   4297     la_br &eoc_lt
   4298     bnez_a0
   4299 
   4300     # ">=" -> EXPR_GE (check before single ">")
   4301     la_a0 &eoc_tok
   4302     ld_a0,a0,0
   4303     la_a1 &op_ge
   4304     li_a2 %2 %0
   4305     la_br &tok_eq_const
   4306     call
   4307     la_br &eoc_ge
   4308     bnez_a0
   4309 
   4310     # ">" -> EXPR_GT
   4311     la_a0 &eoc_tok
   4312     ld_a0,a0,0
   4313     la_a1 &op_gt
   4314     li_a2 %1 %0
   4315     la_br &tok_eq_const
   4316     call
   4317     la_br &eoc_gt
   4318     bnez_a0
   4319 
   4320     # "strlen" -> EXPR_STRLEN
   4321     la_a0 &eoc_tok
   4322     ld_a0,a0,0
   4323     la_a1 &op_strlen
   4324     li_a2 %6 %0
   4325     la_br &tok_eq_const
   4326     call
   4327     la_br &eoc_strlen
   4328     bnez_a0
   4329 
   4330 :eoc_invalid
   4331     li_a0 EXPR_INVALID
   4332     eret
   4333 :eoc_add
   4334     li_a0 EXPR_ADD
   4335     eret
   4336 :eoc_sub
   4337     li_a0 EXPR_SUB
   4338     eret
   4339 :eoc_mul
   4340     li_a0 EXPR_MUL
   4341     eret
   4342 :eoc_div
   4343     li_a0 EXPR_DIV
   4344     eret
   4345 :eoc_mod
   4346     li_a0 EXPR_MOD
   4347     eret
   4348 :eoc_shl
   4349     li_a0 EXPR_SHL
   4350     eret
   4351 :eoc_shr
   4352     li_a0 EXPR_SHR
   4353     eret
   4354 :eoc_and
   4355     li_a0 EXPR_AND
   4356     eret
   4357 :eoc_or
   4358     li_a0 EXPR_OR
   4359     eret
   4360 :eoc_xor
   4361     li_a0 EXPR_XOR
   4362     eret
   4363 :eoc_not
   4364     li_a0 EXPR_NOT
   4365     eret
   4366 :eoc_eq
   4367     li_a0 EXPR_EQ
   4368     eret
   4369 :eoc_ne
   4370     li_a0 EXPR_NE
   4371     eret
   4372 :eoc_lt
   4373     li_a0 EXPR_LT
   4374     eret
   4375 :eoc_le
   4376     li_a0 EXPR_LE
   4377     eret
   4378 :eoc_gt
   4379     li_a0 EXPR_GT
   4380     eret
   4381 :eoc_ge
   4382     li_a0 EXPR_GE
   4383     eret
   4384 :eoc_strlen
   4385     li_a0 EXPR_STRLEN
   4386     eret
   4387 
   4388 ## apply_expr_op(a0=op_code, a1=args_ptr, a2=argc) -> a0 = i64 result
   4389 ## Reduce args[0..argc) per op:
   4390 ##   + * & | $      variadic, argc >= 1
   4391 ##   -              argc >= 1 (argc == 1 is negate, else left-assoc subtract)
   4392 ##   / %            binary, div-by-zero fatal
   4393 ##   << >>          binary (>> is arithmetic)
   4394 ##   ~              unary
   4395 ##   = == != < <= > >=  binary
   4396 ## Fatal on wrong argc or EXPR_INVALID.
   4397 ##
   4398 ## Calls aeo_require_* helpers via `call`, so it needs a frame.
   4399 ## State held in BSS scratch (aeo_op/args/argc/acc/i) since loops trash registers.
   4400 :apply_expr_op
   4401     enter_0
   4402     # spill op, args, argc to BSS
   4403     la_a3 &aeo_op
   4404     st_a0,a3,0
   4405     la_a3 &aeo_args
   4406     st_a1,a3,0
   4407     la_a3 &aeo_argc
   4408     st_a2,a3,0
   4409 
   4410     # dispatch: compare op against each EXPR_* and branch to its handler
   4411     li_t0 EXPR_ADD
   4412     la_br &aeo_do_add
   4413     beq_a0,t0
   4414     li_t0 EXPR_SUB
   4415     la_br &aeo_do_sub
   4416     beq_a0,t0
   4417     li_t0 EXPR_MUL
   4418     la_br &aeo_do_mul
   4419     beq_a0,t0
   4420     li_t0 EXPR_DIV
   4421     la_br &aeo_do_div
   4422     beq_a0,t0
   4423     li_t0 EXPR_MOD
   4424     la_br &aeo_do_mod
   4425     beq_a0,t0
   4426     li_t0 EXPR_SHL
   4427     la_br &aeo_do_shl
   4428     beq_a0,t0
   4429     li_t0 EXPR_SHR
   4430     la_br &aeo_do_shr
   4431     beq_a0,t0
   4432     li_t0 EXPR_AND
   4433     la_br &aeo_do_and
   4434     beq_a0,t0
   4435     li_t0 EXPR_OR
   4436     la_br &aeo_do_or
   4437     beq_a0,t0
   4438     li_t0 EXPR_XOR
   4439     la_br &aeo_do_xor
   4440     beq_a0,t0
   4441     li_t0 EXPR_NOT
   4442     la_br &aeo_do_not
   4443     beq_a0,t0
   4444     li_t0 EXPR_EQ
   4445     la_br &aeo_do_eq
   4446     beq_a0,t0
   4447     li_t0 EXPR_NE
   4448     la_br &aeo_do_ne
   4449     beq_a0,t0
   4450     li_t0 EXPR_LT
   4451     la_br &aeo_do_lt
   4452     beq_a0,t0
   4453     li_t0 EXPR_LE
   4454     la_br &aeo_do_le
   4455     beq_a0,t0
   4456     li_t0 EXPR_GT
   4457     la_br &aeo_do_gt
   4458     beq_a0,t0
   4459     li_t0 EXPR_GE
   4460     la_br &aeo_do_ge
   4461     beq_a0,t0
   4462     # EXPR_INVALID or unknown
   4463     la_br &err_bad_macro_header
   4464     b
   4465 
   4466 ## --- shared helpers for variadic folds ----------------------------------
   4467 ## aeo_require_argc_ge1: branch to err if argc < 1
   4468 ## aeo_require_argc_eq2: branch to err if argc != 2
   4469 ## aeo_load_arg0_to_acc: acc = args[0]; i = 1
   4470 
   4471 :aeo_do_add
   4472     la_br &aeo_require_argc_ge1
   4473     call
   4474     la_br &aeo_load_arg0_to_acc
   4475     call
   4476 :aeo_add_loop
   4477     la_a0 &aeo_i
   4478     ld_t0,a0,0
   4479     la_a1 &aeo_argc
   4480     ld_t1,a1,0
   4481     la_br &aeo_finish
   4482     beq_t0,t1
   4483     # acc += args[i]
   4484     la_a0 &aeo_args
   4485     ld_a1,a0,0
   4486     shli_t2,t0,3
   4487     add_t2,a1,t2
   4488     ld_a2,t2,0
   4489     la_a0 &aeo_acc
   4490     ld_a3,a0,0
   4491     add_a3,a3,a2
   4492     st_a3,a0,0
   4493     addi_t0,t0,1
   4494     la_a1 &aeo_i
   4495     st_t0,a1,0
   4496     la_br &aeo_add_loop
   4497     b
   4498 
   4499 :aeo_do_sub
   4500     la_br &aeo_require_argc_ge1
   4501     call
   4502     # if (argc == 1) acc = -args[0]; else acc = args[0]
   4503     la_a0 &aeo_argc
   4504     ld_t0,a0,0
   4505     li_t1 %1 %0
   4506     la_br &aeo_sub_unary
   4507     beq_t0,t1
   4508     la_br &aeo_load_arg0_to_acc
   4509     call
   4510 :aeo_sub_loop
   4511     la_a0 &aeo_i
   4512     ld_t0,a0,0
   4513     la_a1 &aeo_argc
   4514     ld_t1,a1,0
   4515     la_br &aeo_finish
   4516     beq_t0,t1
   4517     la_a0 &aeo_args
   4518     ld_a1,a0,0
   4519     shli_t2,t0,3
   4520     add_t2,a1,t2
   4521     ld_a2,t2,0
   4522     la_a0 &aeo_acc
   4523     ld_a3,a0,0
   4524     sub_a3,a3,a2
   4525     st_a3,a0,0
   4526     addi_t0,t0,1
   4527     la_a1 &aeo_i
   4528     st_t0,a1,0
   4529     la_br &aeo_sub_loop
   4530     b
   4531 :aeo_sub_unary
   4532     # acc = 0 - args[0]
   4533     la_a0 &aeo_args
   4534     ld_a1,a0,0
   4535     ld_a2,a1,0
   4536     li_a3 %0 %0
   4537     sub_a3,a3,a2
   4538     la_a0 &aeo_acc
   4539     st_a3,a0,0
   4540     la_br &aeo_finish
   4541     b
   4542 
   4543 :aeo_do_mul
   4544     la_br &aeo_require_argc_ge1
   4545     call
   4546     la_br &aeo_load_arg0_to_acc
   4547     call
   4548 :aeo_mul_loop
   4549     la_a0 &aeo_i
   4550     ld_t0,a0,0
   4551     la_a1 &aeo_argc
   4552     ld_t1,a1,0
   4553     la_br &aeo_finish
   4554     beq_t0,t1
   4555     la_a0 &aeo_args
   4556     ld_a1,a0,0
   4557     shli_t2,t0,3
   4558     add_t2,a1,t2
   4559     ld_a2,t2,0
   4560     la_a0 &aeo_acc
   4561     ld_a3,a0,0
   4562     mul_a3,a3,a2
   4563     st_a3,a0,0
   4564     addi_t0,t0,1
   4565     la_a1 &aeo_i
   4566     st_t0,a1,0
   4567     la_br &aeo_mul_loop
   4568     b
   4569 
   4570 :aeo_do_div
   4571     la_br &aeo_require_argc_eq2
   4572     call
   4573     la_a0 &aeo_args
   4574     ld_a1,a0,0
   4575     ld_a2,a1,0
   4576     ld_a3,a1,8
   4577     # if (args[1] == 0) fatal
   4578     la_br &err_bad_macro_header
   4579     beqz_a3
   4580     div_a2,a2,a3
   4581     la_a0 &aeo_acc
   4582     st_a2,a0,0
   4583     la_br &aeo_finish
   4584     b
   4585 
   4586 :aeo_do_mod
   4587     la_br &aeo_require_argc_eq2
   4588     call
   4589     la_a0 &aeo_args
   4590     ld_a1,a0,0
   4591     ld_a2,a1,0
   4592     ld_a3,a1,8
   4593     la_br &err_bad_macro_header
   4594     beqz_a3
   4595     rem_a2,a2,a3
   4596     la_a0 &aeo_acc
   4597     st_a2,a0,0
   4598     la_br &aeo_finish
   4599     b
   4600 
   4601 :aeo_do_shl
   4602     la_br &aeo_require_argc_eq2
   4603     call
   4604     la_a0 &aeo_args
   4605     ld_a1,a0,0
   4606     ld_a2,a1,0
   4607     ld_a3,a1,8
   4608     shl_a2,a2,a3
   4609     la_a0 &aeo_acc
   4610     st_a2,a0,0
   4611     la_br &aeo_finish
   4612     b
   4613 
   4614 :aeo_do_shr
   4615     la_br &aeo_require_argc_eq2
   4616     call
   4617     la_a0 &aeo_args
   4618     ld_a1,a0,0
   4619     ld_a2,a1,0
   4620     ld_a3,a1,8
   4621     sar_a2,a2,a3
   4622     la_a0 &aeo_acc
   4623     st_a2,a0,0
   4624     la_br &aeo_finish
   4625     b
   4626 
   4627 :aeo_do_and
   4628     la_br &aeo_require_argc_ge1
   4629     call
   4630     la_br &aeo_load_arg0_to_acc
   4631     call
   4632 :aeo_and_loop
   4633     la_a0 &aeo_i
   4634     ld_t0,a0,0
   4635     la_a1 &aeo_argc
   4636     ld_t1,a1,0
   4637     la_br &aeo_finish
   4638     beq_t0,t1
   4639     la_a0 &aeo_args
   4640     ld_a1,a0,0
   4641     shli_t2,t0,3
   4642     add_t2,a1,t2
   4643     ld_a2,t2,0
   4644     la_a0 &aeo_acc
   4645     ld_a3,a0,0
   4646     and_a3,a3,a2
   4647     st_a3,a0,0
   4648     addi_t0,t0,1
   4649     la_a1 &aeo_i
   4650     st_t0,a1,0
   4651     la_br &aeo_and_loop
   4652     b
   4653 
   4654 :aeo_do_or
   4655     la_br &aeo_require_argc_ge1
   4656     call
   4657     la_br &aeo_load_arg0_to_acc
   4658     call
   4659 :aeo_or_loop
   4660     la_a0 &aeo_i
   4661     ld_t0,a0,0
   4662     la_a1 &aeo_argc
   4663     ld_t1,a1,0
   4664     la_br &aeo_finish
   4665     beq_t0,t1
   4666     la_a0 &aeo_args
   4667     ld_a1,a0,0
   4668     shli_t2,t0,3
   4669     add_t2,a1,t2
   4670     ld_a2,t2,0
   4671     la_a0 &aeo_acc
   4672     ld_a3,a0,0
   4673     or_a3,a3,a2
   4674     st_a3,a0,0
   4675     addi_t0,t0,1
   4676     la_a1 &aeo_i
   4677     st_t0,a1,0
   4678     la_br &aeo_or_loop
   4679     b
   4680 
   4681 :aeo_do_xor
   4682     la_br &aeo_require_argc_ge1
   4683     call
   4684     la_br &aeo_load_arg0_to_acc
   4685     call
   4686 :aeo_xor_loop
   4687     la_a0 &aeo_i
   4688     ld_t0,a0,0
   4689     la_a1 &aeo_argc
   4690     ld_t1,a1,0
   4691     la_br &aeo_finish
   4692     beq_t0,t1
   4693     la_a0 &aeo_args
   4694     ld_a1,a0,0
   4695     shli_t2,t0,3
   4696     add_t2,a1,t2
   4697     ld_a2,t2,0
   4698     la_a0 &aeo_acc
   4699     ld_a3,a0,0
   4700     xor_a3,a3,a2
   4701     st_a3,a0,0
   4702     addi_t0,t0,1
   4703     la_a1 &aeo_i
   4704     st_t0,a1,0
   4705     la_br &aeo_xor_loop
   4706     b
   4707 
   4708 :aeo_do_not
   4709     # require argc == 1
   4710     la_a0 &aeo_argc
   4711     ld_t0,a0,0
   4712     li_t1 %1 %0
   4713     la_br &err_bad_macro_header
   4714     bne_t0,t1
   4715     la_a0 &aeo_args
   4716     ld_a1,a0,0
   4717     ld_a2,a1,0
   4718     # ~x = x XOR -1
   4719     li_a3 %1 %0
   4720     li_t0 %0 %0
   4721     sub_a3,t0,a3
   4722     xor_a2,a2,a3
   4723     la_a0 &aeo_acc
   4724     st_a2,a0,0
   4725     la_br &aeo_finish
   4726     b
   4727 
   4728 ## --- comparison ops: return 0 or 1 ----------------------------------------
   4729 ## EQ:  args[0] == args[1]
   4730 ## NE:  args[0] != args[1]
   4731 ## LT:  args[0] <  args[1]   (signed)
   4732 ## LE:  args[0] <= args[1]   (signed)
   4733 ## GT:  args[0] >  args[1]   (signed)
   4734 ## GE:  args[0] >= args[1]   (signed)
   4735 
   4736 :aeo_do_eq
   4737     la_br &aeo_require_argc_eq2
   4738     call
   4739     la_a0 &aeo_args
   4740     ld_a1,a0,0
   4741     ld_a2,a1,0
   4742     ld_a3,a1,8
   4743     li_t0 %0 %0
   4744     la_br &aeo_cmp_finish
   4745     bne_a2,a3
   4746     li_t0 %1 %0
   4747     la_br &aeo_cmp_finish
   4748     b
   4749 
   4750 :aeo_do_ne
   4751     la_br &aeo_require_argc_eq2
   4752     call
   4753     la_a0 &aeo_args
   4754     ld_a1,a0,0
   4755     ld_a2,a1,0
   4756     ld_a3,a1,8
   4757     li_t0 %0 %0
   4758     la_br &aeo_cmp_finish
   4759     beq_a2,a3
   4760     li_t0 %1 %0
   4761     la_br &aeo_cmp_finish
   4762     b
   4763 
   4764 :aeo_do_lt
   4765     la_br &aeo_require_argc_eq2
   4766     call
   4767     la_a0 &aeo_args
   4768     ld_a1,a0,0
   4769     ld_a2,a1,0
   4770     ld_a3,a1,8
   4771     li_t0 %1 %0
   4772     la_br &aeo_cmp_finish
   4773     blt_a2,a3
   4774     li_t0 %0 %0
   4775     la_br &aeo_cmp_finish
   4776     b
   4777 
   4778 :aeo_do_le
   4779     # a[0] <= a[1]   <=>   !(a[1] < a[0])
   4780     la_br &aeo_require_argc_eq2
   4781     call
   4782     la_a0 &aeo_args
   4783     ld_a1,a0,0
   4784     ld_a2,a1,0
   4785     ld_a3,a1,8
   4786     li_t0 %0 %0
   4787     la_br &aeo_cmp_finish
   4788     blt_a3,a2
   4789     li_t0 %1 %0
   4790     la_br &aeo_cmp_finish
   4791     b
   4792 
   4793 :aeo_do_gt
   4794     # a[0] > a[1]   <=>   a[1] < a[0]
   4795     la_br &aeo_require_argc_eq2
   4796     call
   4797     la_a0 &aeo_args
   4798     ld_a1,a0,0
   4799     ld_a2,a1,0
   4800     ld_a3,a1,8
   4801     li_t0 %1 %0
   4802     la_br &aeo_cmp_finish
   4803     blt_a3,a2
   4804     li_t0 %0 %0
   4805     la_br &aeo_cmp_finish
   4806     b
   4807 
   4808 :aeo_do_ge
   4809     # a[0] >= a[1]   <=>   !(a[0] < a[1])
   4810     la_br &aeo_require_argc_eq2
   4811     call
   4812     la_a0 &aeo_args
   4813     ld_a1,a0,0
   4814     ld_a2,a1,0
   4815     ld_a3,a1,8
   4816     li_t0 %0 %0
   4817     la_br &aeo_cmp_finish
   4818     blt_a2,a3
   4819     li_t0 %1 %0
   4820     la_br &aeo_cmp_finish
   4821     b
   4822 
   4823 ## Shared tail for the comparison ops: store t0 (the 0/1 result) into
   4824 ## aeo_acc, then jump to aeo_finish. Reached only via `b`.
   4825 :aeo_cmp_finish
   4826     la_a0 &aeo_acc
   4827     st_t0,a0,0
   4828     la_br &aeo_finish
   4829     b
   4830 
   4831 :aeo_finish
   4832     la_a0 &aeo_acc
   4833     ld_a0,a0,0
   4834     eret
   4835 
   4836 ## helper: validate argc >= 1; fatal otherwise. (Returns to caller.)
   4837 :aeo_require_argc_ge1
   4838     la_a0 &aeo_argc
   4839     ld_t0,a0,0
   4840     li_t1 %1 %0
   4841     la_br &err_bad_macro_header
   4842     blt_t0,t1
   4843     ret
   4844 
   4845 ## helper: validate argc == 2; fatal otherwise.
   4846 :aeo_require_argc_eq2
   4847     la_a0 &aeo_argc
   4848     ld_t0,a0,0
   4849     li_t1 %2 %0
   4850     la_br &err_bad_macro_header
   4851     bne_t0,t1
   4852     ret
   4853 
   4854 ## helper: acc = args[0]; i = 1.
   4855 :aeo_load_arg0_to_acc
   4856     la_a0 &aeo_args
   4857     ld_a1,a0,0
   4858     ld_a2,a1,0
   4859     la_a0 &aeo_acc
   4860     st_a2,a0,0
   4861     li_t0 %1 %0
   4862     la_a0 &aeo_i
   4863     st_t0,a0,0
   4864     ret
   4865 
   4866 ## skip_expr_newlines(a0=pos, a1=end) -> a0 = new pos. Leaf.
   4867 ## Advance pos past consecutive TOK_NEWLINE tokens so expressions may span
   4868 ## lines. Also used by directive header parsers to make whitespace
   4869 ## (newlines specifically) insignificant inside %macro/%struct/%frame
   4870 ## headers and around `##` paste operands.
   4871 :skip_expr_newlines
   4872 :sen_loop
   4873     # if (pos == end) done
   4874     la_br &sen_done
   4875     beq_a0,a1
   4876     # if (pos->kind != TOK_NEWLINE) done
   4877     ld_t0,a0,0
   4878     li_t1 TOK_NEWLINE
   4879     la_br &sen_done
   4880     bne_t0,t1
   4881     # pos += 24
   4882     addi_a0,a0,32
   4883     la_br &sen_loop
   4884     b
   4885 :sen_done
   4886     ret
   4887 
   4888 ## proc_skip_newlines(): advance proc_pos past TOK_NEWLINE tokens, bounded by
   4889 ## source_end. Convenience wrapper used by directive header parsers.
   4890 :proc_skip_newlines
   4891 :psn_loop
   4892     la_a0 &proc_pos
   4893     ld_t0,a0,0
   4894     la_a1 &source_end
   4895     ld_t1,a1,0
   4896     la_br &psn_done
   4897     beq_t0,t1
   4898     ld_a2,t0,0
   4899     li_a3 TOK_NEWLINE
   4900     la_br &psn_done
   4901     bne_a2,a3
   4902     addi_t0,t0,32
   4903     st_t0,a0,0
   4904     la_br &psn_loop
   4905     b
   4906 :psn_done
   4907     ret
   4908 
   4909 ## eval_expr_atom(a0=tok, a1=limit) -> void
   4910 ## Outputs via globals:
   4911 ##   eval_after_pos = token one past the consumed atom (or one past ')' for
   4912 ##                    a macro atom)
   4913 ##   eval_value     = the atom's i64 value
   4914 ##
   4915 ## If tok is a defined macro followed by TOK_LPAREN: expand_macro_tokens into
   4916 ## the pool at mark = pool_used, recursively eval_expr_range over the new
   4917 ## slice, require exactly one value (no trailing tokens), restore
   4918 ## pool_used = mark, and set eval_after_pos = emt_after_pos. Otherwise
   4919 ## parse_int_token(tok) and set eval_after_pos = tok + 24 bytes.
   4920 ##
   4921 ## CAVEAT: this path can recurse through eval_expr_range. Callers MUST
   4922 ## snapshot eval_after_pos / eval_value into local stack slots (via
   4923 ## enter_N) before any further call that might overwrite them.
   4924 ##
   4925 ## Stack-local layout (enter_40):
   4926 ##   sp+16  saved tok
   4927 ##   sp+24  saved limit
   4928 ##   sp+32  macro_ptr (find_macro result)
   4929 ##   sp+40  saved emt_after_pos
   4930 ##   sp+48  saved emt_mark
   4931 :eval_expr_atom
   4932     enter_40
   4933     st_a0,sp,0
   4934     st_a1,sp,8
   4935 
   4936     # ---- tok eq "%local" with tight ( -> expand and recurse over body ----
   4937     # %local is a built-in (not a macro) but expands to an integer-yielding
   4938     # token sequence, so eval_expr_atom must handle it before the find_macro
   4939     # path. Validation (tight LPAREN, arg shape, frame_active, name lookup)
   4940     # is centralized in expand_local_into_pool.
   4941     ld_a0,sp,0
   4942     la_a1 &const_local
   4943     li_a2 %6 %0
   4944     la_br &tok_eq_const
   4945     call
   4946     la_br &eea_skip_local
   4947     beqz_a0
   4948 
   4949     # Confirm tight LPAREN follows; otherwise treat %local as opaque text and
   4950     # let the integer-atom path fail with a useful error.
   4951     ld_t0,sp,0
   4952     addi_t0,t0,32
   4953     ld_t1,sp,8
   4954     la_br &eea_skip_local
   4955     blt_t1,t0
   4956     la_br &eea_skip_local
   4957     beq_t0,t1
   4958     ld_a3,t0,0
   4959     li_a2 TOK_LPAREN
   4960     la_br &eea_skip_local
   4961     bne_a3,a2
   4962     ld_a3,t0,24
   4963     la_br &eea_skip_local
   4964     beqz_a3
   4965 
   4966     # Dispatch to the local-expansion path.
   4967     ld_a0,sp,0
   4968     ld_a1,sp,8
   4969     la_br &expand_local_into_pool
   4970     call
   4971 
   4972     # Snapshot elp_mark / elp_after before recursing.
   4973     la_a0 &elp_after
   4974     ld_t0,a0,0
   4975     st_t0,sp,24
   4976     la_a0 &elp_mark
   4977     ld_t0,a0,0
   4978     st_t0,sp,32
   4979 
   4980     # If pool was not extended (pool_used == mark) -> bad expression.
   4981     la_a0 &pool_used
   4982     ld_t0,a0,0
   4983     ld_t1,sp,32
   4984     la_br &err_bad_macro_header
   4985     beq_t0,t1
   4986 
   4987     # eval_expr_range(expand_pool + mark, expand_pool + pool_used)
   4988     la_a0 &expand_pool_ptr
   4989     ld_a0,a0,0
   4990     ld_t1,sp,32
   4991     add_a0,a0,t1
   4992     la_a1 &expand_pool_ptr
   4993     ld_a1,a1,0
   4994     la_a2 &pool_used
   4995     ld_a2,a2,0
   4996     add_a1,a1,a2
   4997     la_br &eval_expr_range
   4998     call
   4999 
   5000     la_a1 &eval_value
   5001     st_a0,a1,0
   5002 
   5003     # restore pool_used = mark
   5004     la_a0 &pool_used
   5005     ld_t0,sp,32
   5006     st_t0,a0,0
   5007 
   5008     # eval_after_pos = saved elp_after
   5009     la_a0 &eval_after_pos
   5010     ld_t0,sp,24
   5011     st_t0,a0,0
   5012 
   5013     eret
   5014 
   5015 :eea_skip_local
   5016     # macro_ptr = find_macro(tok)
   5017     ld_a0,sp,0
   5018     la_br &find_macro
   5019     call
   5020     st_a0,sp,16
   5021 
   5022     # if (macro_ptr == 0) -> integer atom branch
   5023     la_br &eea_int_atom
   5024     beqz_a0
   5025 
   5026     # Paren-less 0-arg atom:
   5027     #   Take the macro-call branch if (tok+1 < limit AND (tok+1)->kind == TOK_LPAREN
   5028     #   AND (tok+1)->tight) OR macro->param_count == 0. Otherwise fall through
   5029     #   to int atom (unchanged).
   5030     ld_t0,sp,0
   5031     addi_t0,t0,32
   5032     ld_t1,sp,8
   5033     la_br &eea_check_zero_arg
   5034     blt_t1,t0
   5035     la_br &eea_check_zero_arg
   5036     beq_t0,t1
   5037     ld_t2,t0,0
   5038     li_a3 TOK_LPAREN
   5039     la_br &eea_check_zero_arg
   5040     bne_t2,a3
   5041     ld_t2,t0,24
   5042     la_br &eea_check_zero_arg
   5043     beqz_t2
   5044     la_br &eea_do_macro
   5045     b
   5046 
   5047 :eea_check_zero_arg
   5048     # No trailing LPAREN. Take the macro branch only if param_count == 0.
   5049     ld_t0,sp,16
   5050     ld_t1,t0,16
   5051     la_br &eea_int_atom
   5052     bnez_t1
   5053 
   5054 :eea_do_macro
   5055     # Macro call branch:
   5056     #   expand_macro_tokens(tok, limit, macro_ptr)
   5057     ld_a0,sp,0
   5058     ld_a1,sp,8
   5059     ld_a2,sp,16
   5060     la_br &expand_macro_tokens
   5061     call
   5062 
   5063     # Snapshot emt outputs immediately.
   5064     la_a0 &emt_after_pos
   5065     ld_t0,a0,0
   5066     st_t0,sp,24
   5067     la_a0 &emt_mark
   5068     ld_t0,a0,0
   5069     st_t0,sp,32
   5070 
   5071     # If pool was not extended (pool_used == mark) -> bad expression.
   5072     la_a0 &pool_used
   5073     ld_t0,a0,0
   5074     ld_t1,sp,32
   5075     la_br &err_bad_macro_header
   5076     beq_t0,t1
   5077 
   5078     # eval_expr_range(expand_pool + mark, expand_pool + pool_used)
   5079     la_a0 &expand_pool_ptr
   5080     ld_a0,a0,0
   5081     ld_t1,sp,32
   5082     add_a0,a0,t1
   5083     la_a1 &expand_pool_ptr
   5084     ld_a1,a1,0
   5085     la_a2 &pool_used
   5086     ld_a2,a2,0
   5087     add_a1,a1,a2
   5088     la_br &eval_expr_range
   5089     call
   5090 
   5091     # eval_value = result
   5092     la_a1 &eval_value
   5093     st_a0,a1,0
   5094 
   5095     # restore pool_used = mark
   5096     la_a0 &pool_used
   5097     ld_t0,sp,32
   5098     st_t0,a0,0
   5099 
   5100     # eval_after_pos = saved emt_after_pos
   5101     la_a0 &eval_after_pos
   5102     ld_t0,sp,24
   5103     st_t0,a0,0
   5104 
   5105     eret
   5106 
   5107 :eea_int_atom
   5108     # parse_int_token(tok) -> i64
   5109     ld_a0,sp,0
   5110     la_br &parse_int_token
   5111     call
   5112     la_a1 &eval_value
   5113     st_a0,a1,0
   5114 
   5115     # eval_after_pos = tok + 24
   5116     ld_t0,sp,0
   5117     addi_t0,t0,32
   5118     la_a0 &eval_after_pos
   5119     st_t0,a0,0
   5120 
   5121     eret
   5122 
   5123 ## eval_expr_range(a0=start_tok, a1=end_tok) -> a0 = i64 result (fatal on bad)
   5124 ## Main S-expression evaluator loop, driven by the explicit ExprFrame stack
   5125 ## in expr_frames[] / expr_frame_top — NOT by P1 recursion (eval_expr_atom
   5126 ## can re-enter eval_expr_range through expand_macro_tokens, and a P1
   5127 ## recursion would defeat the bounded frame budget). Enforces exactly one
   5128 ## top-level value and no trailing tokens.
   5129 ## Fatal on: unmatched parens, > 16 frames deep, > 16 args per frame,
   5130 ## bad atom, bad operator.
   5131 ## Reads/writes: expr_frames, expr_frame_top.
   5132 ##
   5133 ## Stack-local layout (enter_56):
   5134 ##   sp+16  pos              Token*
   5135 ##   sp+24  end              Token*
   5136 ##   sp+32  value            i64 (most recent atom or rparen result)
   5137 ##   sp+40  result           i64 (set when have_result transitions to 1)
   5138 ##   sp+48  have_value       0/1
   5139 ##   sp+56  have_result      0/1
   5140 ##   sp+64  entry_frame_top  i64 (snapshot at entry; restored on exit;
   5141 ##                                used as the local base for stack checks)
   5142 :eval_expr_range
   5143     enter_56
   5144     st_a0,sp,0
   5145     st_a1,sp,8
   5146     li_t0 %0 %0
   5147     st_t0,sp,16
   5148     st_t0,sp,24
   5149     st_t0,sp,32
   5150     st_t0,sp,40
   5151     # entry_frame_top = expr_frame_top
   5152     la_a0 &expr_frame_top
   5153     ld_t0,a0,0
   5154     st_t0,sp,48
   5155 
   5156 :eer_loop
   5157     # If have_value, deliver it.
   5158     ld_t0,sp,32
   5159     la_br &eer_no_have_value
   5160     beqz_t0
   5161 
   5162     # have_value: feed into top frame, or set result.
   5163     la_a0 &expr_frame_top
   5164     ld_t0,a0,0
   5165     ld_t1,sp,48
   5166     la_br &eer_set_result
   5167     beq_t0,t1
   5168     # frame = &expr_frames[frame_top - 1]
   5169     addi_t0,t0,neg1
   5170     li_a1 M1PP_EXPR_FRAME_SIZE
   5171     mul_t0,t0,a1
   5172     la_a0 &expr_frames_ptr
   5173     ld_a0,a0,0
   5174     add_a0,a0,t0
   5175     # if (frame->argc >= MAX_PARAMS) fatal
   5176     li_a1 M1PP_EXPR_ARGC_OFF
   5177     add_a1,a0,a1
   5178     ld_t1,a1,0
   5179     li_a2 M1PP_MAX_PARAMS
   5180     la_br &err_bad_macro_header
   5181     blt_a2,t1
   5182     la_br &err_bad_macro_header
   5183     beq_t1,a2
   5184     # frame->args[argc] = value
   5185     li_a2 M1PP_EXPR_ARGS_OFF
   5186     add_a3,a0,a2
   5187     shli_a2,t1,3
   5188     add_a3,a3,a2
   5189     ld_t2,sp,16
   5190     st_t2,a3,0
   5191     # frame->argc++
   5192     addi_t1,t1,1
   5193     st_t1,a1,0
   5194     # have_value = 0
   5195     li_t0 %0 %0
   5196     st_t0,sp,32
   5197     la_br &eer_loop
   5198     b
   5199 
   5200 :eer_set_result
   5201     # No frame open; this value is the top-level result.
   5202     ld_t0,sp,40
   5203     la_br &err_bad_macro_header
   5204     bnez_t0
   5205     ld_t0,sp,16
   5206     st_t0,sp,24
   5207     li_t0 %1 %0
   5208     st_t0,sp,40
   5209     li_t0 %0 %0
   5210     st_t0,sp,32
   5211     la_br &eer_loop
   5212     b
   5213 
   5214 :eer_no_have_value
   5215     # skip_expr_newlines(pos, end)
   5216     ld_a0,sp,0
   5217     ld_a1,sp,8
   5218     la_br &skip_expr_newlines
   5219     call
   5220     st_a0,sp,0
   5221 
   5222     # if (pos >= end) break
   5223     ld_t0,sp,0
   5224     ld_t1,sp,8
   5225     la_br &eer_loop_done
   5226     beq_t0,t1
   5227 
   5228     # Dispatch on token kind.
   5229     ld_t2,t0,0
   5230     li_a3 TOK_LPAREN
   5231     la_br &eer_lparen
   5232     beq_t2,a3
   5233     li_a3 TOK_RPAREN
   5234     la_br &eer_rparen
   5235     beq_t2,a3
   5236 
   5237     # atom: eval_expr_atom(pos, end); value = eval_value; pos = eval_after_pos
   5238     ld_a0,sp,0
   5239     ld_a1,sp,8
   5240     la_br &eval_expr_atom
   5241     call
   5242     la_a0 &eval_value
   5243     ld_t0,a0,0
   5244     st_t0,sp,16
   5245     la_a0 &eval_after_pos
   5246     ld_t0,a0,0
   5247     st_t0,sp,0
   5248     li_t0 %1 %0
   5249     st_t0,sp,32
   5250     la_br &eer_loop
   5251     b
   5252 
   5253 :eer_lparen
   5254     # pos++
   5255     addi_t0,t0,32
   5256     st_t0,sp,0
   5257     # skip_expr_newlines
   5258     ld_a0,sp,0
   5259     ld_a1,sp,8
   5260     la_br &skip_expr_newlines
   5261     call
   5262     st_a0,sp,0
   5263     # if (pos >= end) fatal
   5264     ld_t0,sp,0
   5265     ld_t1,sp,8
   5266     la_br &err_bad_macro_header
   5267     beq_t0,t1
   5268     # op = expr_op_code(pos)
   5269     ld_a0,sp,0
   5270     la_br &expr_op_code
   5271     call
   5272     # if (op == EXPR_INVALID) fatal
   5273     li_t0 EXPR_INVALID
   5274     la_br &err_bad_macro_header
   5275     beq_a0,t0
   5276     # if (op == EXPR_STRLEN) handle inline — strlen's argument is a
   5277     # TOK_STRING atom, not a recursive expression. Yield text.len - 2.
   5278     li_t0 EXPR_STRLEN
   5279     la_br &eer_strlen
   5280     beq_a0,t0
   5281     # frame stack overflow check: if (expr_frame_top >= 16) fatal
   5282     # (the global expr_frames[] array has 16 slots, shared across recursive
   5283     # eval_expr_range calls)
   5284     la_a1 &expr_frame_top
   5285     ld_t0,a1,0
   5286     li_a2 M1PP_MAX_PARAMS
   5287     la_br &err_bad_macro_header
   5288     blt_a2,t0
   5289     la_br &err_bad_macro_header
   5290     beq_t0,a2
   5291     # frames[frame_top].op = op; frames[frame_top].argc = 0
   5292     li_a2 M1PP_EXPR_FRAME_SIZE
   5293     mul_t2,t0,a2
   5294     la_a3 &expr_frames_ptr
   5295     ld_a3,a3,0
   5296     add_a3,a3,t2
   5297     st_a0,a3,0
   5298     li_a2 M1PP_EXPR_ARGC_OFF
   5299     add_a2,a3,a2
   5300     li_t2 %0 %0
   5301     st_t2,a2,0
   5302     # frame_top++
   5303     addi_t0,t0,1
   5304     st_t0,a1,0
   5305     # pos++ (skip operator token)
   5306     ld_t0,sp,0
   5307     addi_t0,t0,32
   5308     st_t0,sp,0
   5309     la_br &eer_loop
   5310     b
   5311 
   5312 :eer_rparen
   5313     # if (frame_top <= entry_frame_top) fatal
   5314     la_a0 &expr_frame_top
   5315     ld_t0,a0,0
   5316     ld_t1,sp,48
   5317     la_br &err_bad_macro_header
   5318     beq_t0,t1
   5319     la_br &err_bad_macro_header
   5320     blt_t0,t1
   5321     # frame = &expr_frames[frame_top - 1]
   5322     addi_t0,t0,neg1
   5323     li_a1 M1PP_EXPR_FRAME_SIZE
   5324     mul_t0,t0,a1
   5325     la_a3 &expr_frames_ptr
   5326     ld_a3,a3,0
   5327     add_a3,a3,t0
   5328     # apply_expr_op(op, args, argc) -> a0
   5329     ld_a0,a3,0
   5330     li_a1 M1PP_EXPR_ARGS_OFF
   5331     add_a1,a3,a1
   5332     li_a2 M1PP_EXPR_ARGC_OFF
   5333     add_a2,a3,a2
   5334     ld_a2,a2,0
   5335     la_br &apply_expr_op
   5336     call
   5337     # value = result; frame_top--; pos++; have_value = 1
   5338     st_a0,sp,16
   5339     la_a1 &expr_frame_top
   5340     ld_t0,a1,0
   5341     addi_t0,t0,neg1
   5342     st_t0,a1,0
   5343     ld_t0,sp,0
   5344     addi_t0,t0,32
   5345     st_t0,sp,0
   5346     li_t0 %1 %0
   5347     st_t0,sp,32
   5348     la_br &eer_loop
   5349     b
   5350 
   5351 :eer_strlen
   5352     # (strlen "literal") — degenerate unary op whose argument is a
   5353     # TOK_STRING atom, not a recursive expression.
   5354     # pos++ past the "strlen" operator word.
   5355     ld_t0,sp,0
   5356     addi_t0,t0,32
   5357     st_t0,sp,0
   5358     # skip_expr_newlines(pos, end)
   5359     ld_a0,sp,0
   5360     ld_a1,sp,8
   5361     la_br &skip_expr_newlines
   5362     call
   5363     st_a0,sp,0
   5364     # if (pos >= end) fatal
   5365     ld_t0,sp,0
   5366     ld_t1,sp,8
   5367     la_br &err_bad_macro_header
   5368     beq_t0,t1
   5369     # if (pos->kind != TOK_STRING) fatal
   5370     ld_t2,t0,0
   5371     li_a3 TOK_STRING
   5372     la_br &err_bad_macro_header
   5373     bne_t2,a3
   5374     # if (pos->text.len < 2) fatal
   5375     ld_a1,t0,16
   5376     li_a2 %2 %0
   5377     la_br &err_bad_macro_header
   5378     blt_a1,a2
   5379     # if (pos->text.ptr[0] != '"') fatal — rejects single-quoted '..' hex
   5380     ld_a2,t0,8
   5381     lb_a3,a2,0
   5382     li_a0 %34 %0
   5383     la_br &err_bad_macro_header
   5384     bne_a3,a0
   5385     # value = pos->text.len - 2
   5386     addi_a1,a1,neg2
   5387     st_a1,sp,16
   5388     # pos++
   5389     addi_t0,t0,32
   5390     st_t0,sp,0
   5391     # skip_expr_newlines(pos, end)
   5392     ld_a0,sp,0
   5393     ld_a1,sp,8
   5394     la_br &skip_expr_newlines
   5395     call
   5396     st_a0,sp,0
   5397     # if (pos >= end) fatal
   5398     ld_t0,sp,0
   5399     ld_t1,sp,8
   5400     la_br &err_bad_macro_header
   5401     beq_t0,t1
   5402     # if (pos->kind != TOK_RPAREN) fatal
   5403     ld_t2,t0,0
   5404     li_a3 TOK_RPAREN
   5405     la_br &err_bad_macro_header
   5406     bne_t2,a3
   5407     # pos++
   5408     addi_t0,t0,32
   5409     st_t0,sp,0
   5410     # have_value = 1
   5411     li_t0 %1 %0
   5412     st_t0,sp,32
   5413     la_br &eer_loop
   5414     b
   5415 
   5416 :eer_loop_done
   5417     # frame_top must equal entry_frame_top
   5418     la_a0 &expr_frame_top
   5419     ld_t0,a0,0
   5420     ld_t1,sp,48
   5421     la_br &err_bad_macro_header
   5422     bne_t0,t1
   5423     # have_result must be 1
   5424     ld_t0,sp,40
   5425     la_br &err_bad_macro_header
   5426     beqz_t0
   5427     # pos must equal end
   5428     ld_t0,sp,0
   5429     ld_t1,sp,8
   5430     la_br &err_bad_macro_header
   5431     bne_t0,t1
   5432     # return result
   5433     ld_a0,sp,24
   5434     eret
   5435 
   5436 ## ============================================================================
   5437 ## --- Hex emit for !@%$ ------------------------------------------------------
   5438 ## ============================================================================
   5439 
   5440 ## emit_hex_value(a0=value_u64, a1=byte_count) -> void (fatal on overflow)
   5441 ## byte_count must be 1, 2, 4, or 8. Serialize value into (2 * byte_count)
   5442 ## uppercase hex chars, little-endian byte order (byte i at char indices
   5443 ## 2i, 2i+1) as bare hex digits. hex2pp's byte-stream parser groups every
   5444 ## two hex digits into one byte; no quoting or separators are needed.
   5445 ## Total emitted text length = 2 * byte_count; emitted as a TOK_WORD via
   5446 ## append_text + emit_token.
   5447 :emit_hex_value
   5448     enter_0
   5449 
   5450     # ehv_value = value; ehv_bytes = byte_count
   5451     la_a2 &ehv_value
   5452     st_a0,a2,0
   5453     la_a2 &ehv_bytes
   5454     st_a1,a2,0
   5455 
   5456     # i = 0
   5457     li_t0 %0 %0
   5458 :emit_hex_value_loop
   5459     # if (i == bytes) done
   5460     la_a1 &ehv_bytes
   5461     ld_t1,a1,0
   5462     la_br &emit_hex_value_emit
   5463     beq_t0,t1
   5464 
   5465     # byte = ehv_value & 0xFF
   5466     la_a1 &ehv_value
   5467     ld_t2,a1,0
   5468     andi_a3,t2,255
   5469 
   5470     # high = (byte >> 4) & 0x0F  (byte is already in a3)
   5471     shri_a2,a3,4
   5472     andi_a2,a2,15
   5473 
   5474     # low = byte & 0x0F
   5475     andi_a3,a3,15
   5476 
   5477     # scratch[2*i] = hex_chars[high]
   5478     la_a1 &hex_chars
   5479     add_a1,a1,a2
   5480     lb_a2,a1,0
   5481     la_a1 &ehv_scratch
   5482     shli_a3,t0,1
   5483     add_a1,a1,a3
   5484     sb_a2,a1,0
   5485 
   5486     # scratch[2*i+1] = hex_chars[low]   (reload low from byte & 0x0F)
   5487     la_a1 &ehv_value
   5488     ld_t2,a1,0
   5489     andi_a3,t2,255
   5490     andi_a3,a3,15
   5491     la_a1 &hex_chars
   5492     add_a1,a1,a3
   5493     lb_a2,a1,0
   5494     la_a1 &ehv_scratch
   5495     shli_a3,t0,1
   5496     add_a1,a1,a3
   5497     addi_a1,a1,1
   5498     sb_a2,a1,0
   5499 
   5500     # ehv_value >>= 8
   5501     la_a1 &ehv_value
   5502     ld_t2,a1,0
   5503     shri_t2,t2,8
   5504     st_t2,a1,0
   5505 
   5506     # i++
   5507     addi_t0,t0,1
   5508     la_br &emit_hex_value_loop
   5509     b
   5510 
   5511 :emit_hex_value_emit
   5512     # text_ptr = append_text(&ehv_scratch, 2 * ehv_bytes)
   5513     la_a0 &ehv_scratch
   5514     la_a1 &ehv_bytes
   5515     ld_a1,a1,0
   5516     shli_a1,a1,1
   5517     la_br &append_text
   5518     call
   5519 
   5520     # ehv_token.kind = TOK_WORD; ehv_token.text_ptr = text_ptr;
   5521     # ehv_token.text_len = 2 * ehv_bytes; ehv_token.tight = 0.
   5522     la_a2 &ehv_token
   5523     li_a3 TOK_WORD
   5524     st_a3,a2,0
   5525     st_a0,a2,8
   5526     la_a1 &ehv_bytes
   5527     ld_a1,a1,0
   5528     shli_a1,a1,1
   5529     st_a1,a2,16
   5530     li_a1 %0 %0
   5531     st_a1,a2,24
   5532 
   5533     # emit_token(&ehv_token)
   5534     la_a0 &ehv_token
   5535     la_br &emit_token
   5536     call
   5537 
   5538     eret
   5539 
   5540 ## ============================================================================
   5541 ## --- Builtin dispatcher ( ! @ % $ %select %str %local ) --------------------
   5542 ## ============================================================================
   5543 
   5544 ## expand_builtin_call(a0=stream_ptr, a1=builtin_tok) -> void (fatal on bad)
   5545 ## Requires builtin_tok+1 is TOK_LPAREN. Runs parse_args(lparen, stream->end),
   5546 ## then dispatches on builtin_tok->text:
   5547 ##
   5548 ##   "!" "@" "%" "$"
   5549 ##     require arg_count == 1
   5550 ##     eval_expr_range(arg_starts[0], arg_ends[0]) -> value
   5551 ##     stream->pos = call_end_pos; stream->line_start = 0
   5552 ##     emit_hex_value(value, 1 / 2 / 4 / 8 respectively)
   5553 ##
   5554 ##   "%select"
   5555 ##     require arg_count == 3
   5556 ##     eval_expr_range(cond_arg) -> value
   5557 ##     chosen = (value != 0) ? arg1 : arg2
   5558 ##     stream->pos = call_end_pos; stream->line_start = 0
   5559 ##     if chosen is empty, return (no stream push)
   5560 ##     else copy_span_to_pool(chosen) and push_pool_stream_from_mark(mark)
   5561 ##     The unchosen branch is NOT evaluated, validated, or expanded.
   5562 ##
   5563 ## Any other text under a builtin slot -> fatal "bad builtin".
   5564 ## expand_local_into_pool(a0=call_tok, a1=limit) -> writes elp_after, elp_mark
   5565 ## Resolve %local(NAME) against the current frame: assemble the lookup key
   5566 ## "<frame>_FRAME.<NAME>" in local_lookup_scratch, linear-search macros[]
   5567 ## for that name, and copy the matching body into the pool. Errors:
   5568 ##   - call_tok+1 missing / not tight LPAREN: bad_macro_header
   5569 ##   - parse_args fails: propagated
   5570 ##   - arg_count != 1, arg span != 1 token, arg kind != WORD: bad_macro_header
   5571 ##   - frame not active: local_outside_frame
   5572 ##   - assembled name >= 256 bytes: local_name_too_long
   5573 ##   - no matching macro: unknown_local
   5574 ##
   5575 ## On success, elp_mark = pool_used at entry, elp_after = call_end_pos
   5576 ## (Token* one past the call's `)`). Both expand_builtin_call's %local
   5577 ## branch and eval_expr_atom's %local branch consume those.
   5578 :expand_local_into_pool
   5579     enter_0
   5580 
   5581     # --- Validate (call_tok+1) is a tight LPAREN within the stream. ---
   5582     addi_t0,a0,32                  # lparen = call_tok + 32
   5583     la_br &err_bad_macro_header
   5584     blt_a1,t0
   5585     la_br &err_bad_macro_header
   5586     beq_t0,a1
   5587     ld_a2,t0,0
   5588     li_a3 TOK_LPAREN
   5589     la_br &err_bad_macro_header
   5590     bne_a2,a3
   5591     ld_a2,t0,24
   5592     la_br &err_bad_macro_header
   5593     beqz_a2
   5594 
   5595     # --- parse_args(lparen, limit) ---
   5596     mov_a0,t0
   5597     la_br &parse_args
   5598     call
   5599 
   5600     # --- Validate arg shape: arg_count == 1, single 32-byte token, WORD kind. ---
   5601     la_a0 &arg_count
   5602     ld_t0,a0,0
   5603     li_t1 %1 %0
   5604     la_br &err_bad_macro_header
   5605     bne_t0,t1
   5606 
   5607     la_a0 &arg_starts_ptr
   5608     ld_a0,a0,0
   5609     ld_t0,a0,0                     # arg_tok = arg_starts[0]
   5610     la_a1 &arg_ends_ptr
   5611     ld_a1,a1,0
   5612     ld_t1,a1,0                     # arg_end = arg_ends[0]
   5613     sub_t2,t1,t0
   5614     li_a2 %32 %0
   5615     la_br &err_bad_macro_header
   5616     bne_t2,a2
   5617 
   5618     ld_a3,t0,0                     # arg_tok->kind
   5619     li_a2 TOK_WORD
   5620     la_br &err_bad_macro_header
   5621     bne_a3,a2
   5622 
   5623     # Stash arg.text.ptr / arg.text.len for the byte-copy loop below.
   5624     ld_a0,t0,8
   5625     la_a1 &elp_arg_ptr
   5626     st_a0,a1,0
   5627     ld_a0,t0,16
   5628     la_a1 &elp_arg_len
   5629     st_a0,a1,0
   5630 
   5631     # --- frame_active? ---
   5632     la_a1 &frame_active
   5633     ld_a2,a1,0
   5634     la_br &err_local_outside_frame
   5635     beqz_a2
   5636 
   5637     # --- name_len = current_frame_len + 7 + arg_len; must be < 256. ---
   5638     la_a1 &current_frame_len
   5639     ld_a2,a1,0
   5640     la_a1 &elp_arg_len
   5641     ld_a3,a1,0
   5642     add_t0,a2,a3
   5643     addi_t0,t0,7
   5644     la_a1 &elp_name_len
   5645     st_t0,a1,0
   5646     li_t1 %256 %0
   5647     la_br &err_local_name_too_long
   5648     blt_t1,t0
   5649     la_br &err_local_name_too_long
   5650     beq_t0,t1
   5651 
   5652     # --- Build lookup name in local_lookup_scratch. ---
   5653     # First: copy current_frame_ptr[0..frame_len] -> scratch[0..]
   5654     la_a0 &current_frame_ptr
   5655     ld_t0,a0,0                     # frame_ptr
   5656     la_a0 &current_frame_len
   5657     ld_t1,a0,0                     # frame_len
   5658     la_t2 &local_lookup_scratch_ptr
   5659     ld_t2,t2,0                     # scratch_base
   5660     li_a3 %0 %0
   5661 :elp_copy_frame
   5662     la_br &elp_copy_frame_done
   5663     beq_a3,t1
   5664     add_a0,t0,a3
   5665     lb_a0,a0,0
   5666     add_a1,t2,a3
   5667     sb_a0,a1,0
   5668     addi_a3,a3,1
   5669     la_br &elp_copy_frame
   5670     b
   5671 :elp_copy_frame_done
   5672 
   5673     # Advance scratch cursor to scratch + frame_len for the suffix copy.
   5674     add_t2,t2,t1
   5675 
   5676     # Copy const_frame_suffix (7 bytes "_FRAME.") -> scratch[frame_len..]
   5677     la_a0 &const_frame_suffix
   5678     li_t1 %7 %0
   5679     li_a3 %0 %0
   5680 :elp_copy_suffix
   5681     la_br &elp_copy_suffix_done
   5682     beq_a3,t1
   5683     add_a1,a0,a3
   5684     lb_a1,a1,0
   5685     add_t0,t2,a3
   5686     sb_a1,t0,0
   5687     addi_a3,a3,1
   5688     la_br &elp_copy_suffix
   5689     b
   5690 :elp_copy_suffix_done
   5691 
   5692     # Advance past suffix (7 bytes).
   5693     addi_t2,t2,7
   5694 
   5695     # Copy arg bytes -> scratch[frame_len + 7 ..]
   5696     la_a0 &elp_arg_ptr
   5697     ld_t0,a0,0
   5698     la_a0 &elp_arg_len
   5699     ld_t1,a0,0
   5700     li_a3 %0 %0
   5701 :elp_copy_arg
   5702     la_br &elp_copy_arg_done
   5703     beq_a3,t1
   5704     add_a0,t0,a3
   5705     lb_a0,a0,0
   5706     add_a1,t2,a3
   5707     sb_a0,a1,0
   5708     addi_a3,a3,1
   5709     la_br &elp_copy_arg
   5710     b
   5711 :elp_copy_arg_done
   5712 
   5713     # --- Linear search macros[] for an exact name match. ---
   5714     # m (a3) walks from macros_ptr to macros_end (each MACRO_RECORD_SIZE).
   5715     # Match criterion: m->name.len == name_len AND first name_len bytes of
   5716     # m->name.ptr equal local_lookup_scratch. Modeled on find_macro: keep
   5717     # m in a3, reload macros_end into t0 after each iteration, and use
   5718     # a0/a1/a2/t1/t2 as scratch within the inner byte-compare.
   5719     la_a3 &macros_ptr
   5720     ld_a3,a3,0
   5721     la_t0 &macros_end
   5722     ld_t0,t0,0
   5723 :elp_search_loop
   5724     la_br &elp_unknown
   5725     beq_a3,t0
   5726 
   5727     # m->name.len == name_len?
   5728     ld_t1,a3,8
   5729     la_a0 &elp_name_len
   5730     ld_a2,a0,0
   5731     la_br &elp_search_next
   5732     bne_t1,a2
   5733 
   5734     # byte-compare m->name.ptr vs scratch for name_len bytes.
   5735     ld_t1,a3,0                     # name_ptr
   5736     la_a0 &local_lookup_scratch_ptr
   5737     ld_a1,a0,0                     # lookup_ptr
   5738     li_t2 %0 %0
   5739 :elp_search_cmp
   5740     la_br &elp_search_match
   5741     beq_t2,a2
   5742     add_a0,t1,t2
   5743     lb_a0,a0,0
   5744     add_t0,a1,t2
   5745     lb_t0,t0,0
   5746     la_br &elp_search_next
   5747     bne_a0,t0
   5748     addi_t2,t2,1
   5749     la_br &elp_search_cmp
   5750     b
   5751 
   5752 :elp_search_next
   5753     li_t1 M1PP_MACRO_RECORD_SIZE
   5754     add_a3,a3,t1
   5755     la_t0 &macros_end
   5756     ld_t0,t0,0
   5757     la_br &elp_search_loop
   5758     b
   5759 
   5760 :elp_unknown
   5761     la_br &err_unknown_local
   5762     b
   5763 
   5764 :elp_search_match
   5765     # a3 = matched macro pointer. mark = pool_used; copy body span.
   5766     la_a0 &pool_used
   5767     ld_t0,a0,0
   5768     la_a1 &elp_mark
   5769     st_t0,a1,0
   5770 
   5771     li_t0 M1PP_MACRO_BODY_START_OFF
   5772     add_t0,a3,t0
   5773     ld_a0,t0,0                     # body_start
   5774     li_t1 M1PP_MACRO_BODY_END_OFF
   5775     add_t1,a3,t1
   5776     ld_a1,t1,0                     # body_end
   5777     la_br &copy_span_to_pool
   5778     call
   5779 
   5780     # elp_after = call_end_pos
   5781     la_a0 &call_end_pos
   5782     ld_t0,a0,0
   5783     la_a1 &elp_after
   5784     st_t0,a1,0
   5785 
   5786     eret
   5787 
   5788 :expand_builtin_call
   5789     enter_0
   5790 
   5791     # ebc_stream = stream_ptr;  also stash builtin_tok via a register reload path
   5792     la_a2 &ebc_stream
   5793     st_a0,a2,0
   5794 
   5795     # lparen = builtin_tok + 24; if (lparen >= stream->end) fatal
   5796     addi_t0,a1,32
   5797     ld_t1,a0,8           # stream->end
   5798     la_br &err_bad_macro_header
   5799     beq_t0,t1
   5800     la_br &err_bad_macro_header
   5801     blt_t1,t0
   5802 
   5803     # if (lparen->kind != TOK_LPAREN) fatal
   5804     ld_a3,t0,0
   5805     li_a2 TOK_LPAREN
   5806     la_br &err_bad_macro_header
   5807     bne_a3,a2
   5808 
   5809     # parse_args(lparen, stream->end)
   5810     mov_a0,t0
   5811     la_a2 &ebc_stream
   5812     ld_a2,a2,0
   5813     ld_a1,a2,8           # stream->end
   5814     la_br &parse_args
   5815     call
   5816 
   5817     # snapshot call_end_pos -> ebc_call_end_pos
   5818     la_a0 &call_end_pos
   5819     ld_t0,a0,0
   5820     la_a1 &ebc_call_end_pos
   5821     st_t0,a1,0
   5822 
   5823     # dispatch on builtin_tok->text. a1 (builtin_tok) is gone after parse_args,
   5824     # but stream->pos still points at the builtin token (we don't advance it
   5825     # until the dispatched branch sets stream->pos = call_end_pos), so reload
   5826     # builtin_tok from stream->pos.
   5827     la_a0 &ebc_stream
   5828     ld_a0,a0,0
   5829     ld_t0,a0,16          # stream->pos -> builtin_tok
   5830 
   5831     # if tok_eq_const(tok, "!", 1) -> bytes=1
   5832     mov_a0,t0
   5833     la_a1 &const_bang
   5834     li_a2 %1 %0
   5835     la_br &tok_eq_const
   5836     call
   5837     la_br &ebc_arg_set_1
   5838     bnez_a0
   5839 
   5840     # if tok_eq_const(tok, "@", 1) -> bytes=2
   5841     la_a0 &ebc_stream
   5842     ld_a0,a0,0
   5843     ld_a0,a0,16
   5844     la_a1 &const_at
   5845     li_a2 %1 %0
   5846     la_br &tok_eq_const
   5847     call
   5848     la_br &ebc_arg_set_2
   5849     bnez_a0
   5850 
   5851     # if tok_eq_const(tok, "%", 1) -> bytes=4
   5852     la_a0 &ebc_stream
   5853     ld_a0,a0,0
   5854     ld_a0,a0,16
   5855     la_a1 &const_pct
   5856     li_a2 %1 %0
   5857     la_br &tok_eq_const
   5858     call
   5859     la_br &ebc_arg_set_4
   5860     bnez_a0
   5861 
   5862     # if tok_eq_const(tok, "$", 1) -> bytes=8
   5863     la_a0 &ebc_stream
   5864     ld_a0,a0,0
   5865     ld_a0,a0,16
   5866     la_a1 &const_dlr
   5867     li_a2 %1 %0
   5868     la_br &tok_eq_const
   5869     call
   5870     la_br &ebc_arg_set_8
   5871     bnez_a0
   5872 
   5873     # if tok_eq_const(tok, "%select", 7) -> select path
   5874     la_a0 &ebc_stream
   5875     ld_a0,a0,0
   5876     ld_a0,a0,16
   5877     la_a1 &const_select
   5878     li_a2 %7 %0
   5879     la_br &tok_eq_const
   5880     call
   5881     la_br &ebc_select
   5882     bnez_a0
   5883 
   5884     # if tok_eq_const(tok, "%str", 4) -> str path
   5885     la_a0 &ebc_stream
   5886     ld_a0,a0,0
   5887     ld_a0,a0,16
   5888     la_a1 &const_str
   5889     li_a2 %4 %0
   5890     la_br &tok_eq_const
   5891     call
   5892     la_br &ebc_str
   5893     bnez_a0
   5894 
   5895     # if tok_eq_const(tok, "%local", 6) -> local path
   5896     la_a0 &ebc_stream
   5897     ld_a0,a0,0
   5898     ld_a0,a0,16
   5899     la_a1 &const_local
   5900     li_a2 %6 %0
   5901     la_br &tok_eq_const
   5902     call
   5903     la_br &ebc_local
   5904     bnez_a0
   5905 
   5906     # else: fatal
   5907     la_br &err_bad_macro_header
   5908     b
   5909 
   5910 :ebc_arg_set_1
   5911     li_a0 %1 %0
   5912     la_a1 &ebc_bytes
   5913     st_a0,a1,0
   5914     la_br &ebc_arg_path
   5915     b
   5916 :ebc_arg_set_2
   5917     li_a0 %2 %0
   5918     la_a1 &ebc_bytes
   5919     st_a0,a1,0
   5920     la_br &ebc_arg_path
   5921     b
   5922 :ebc_arg_set_4
   5923     li_a0 %4 %0
   5924     la_a1 &ebc_bytes
   5925     st_a0,a1,0
   5926     la_br &ebc_arg_path
   5927     b
   5928 :ebc_arg_set_8
   5929     li_a0 %8 %0
   5930     la_a1 &ebc_bytes
   5931     st_a0,a1,0
   5932     la_br &ebc_arg_path
   5933     b
   5934 
   5935 :ebc_arg_path
   5936     # require arg_count == 1
   5937     la_a0 &arg_count
   5938     ld_t0,a0,0
   5939     li_t1 %1 %0
   5940     la_br &err_bad_macro_header
   5941     bne_t0,t1
   5942 
   5943     # snapshot arg_starts[0], arg_ends[0]
   5944     la_a0 &arg_starts_ptr
   5945     ld_a0,a0,0
   5946     ld_t0,a0,0
   5947     la_a1 &ebc_arg0_start
   5948     st_t0,a1,0
   5949     la_a0 &arg_ends_ptr
   5950     ld_a0,a0,0
   5951     ld_t0,a0,0
   5952     la_a1 &ebc_arg0_end
   5953     st_t0,a1,0
   5954 
   5955     # value = eval_expr_range(arg0_start, arg0_end)
   5956     la_a0 &ebc_arg0_start
   5957     ld_a0,a0,0
   5958     la_a1 &ebc_arg0_end
   5959     ld_a1,a1,0
   5960     la_br &eval_expr_range
   5961     call
   5962 
   5963     # ebc_value = a0
   5964     la_a1 &ebc_value
   5965     st_a0,a1,0
   5966 
   5967     # stream->pos = ebc_call_end_pos; stream->line_start = 0
   5968     la_a0 &ebc_stream
   5969     ld_a0,a0,0
   5970     la_a1 &ebc_call_end_pos
   5971     ld_t0,a1,0
   5972     st_t0,a0,16
   5973     li_t1 %0 %0
   5974     st_t1,a0,24
   5975 
   5976     # emit_hex_value(ebc_value, ebc_bytes)
   5977     la_a0 &ebc_value
   5978     ld_a0,a0,0
   5979     la_a1 &ebc_bytes
   5980     ld_a1,a1,0
   5981     la_br &emit_hex_value
   5982     call
   5983 
   5984     eret
   5985 
   5986 :ebc_select
   5987     # require arg_count == 3
   5988     la_a0 &arg_count
   5989     ld_t0,a0,0
   5990     li_t1 %3 %0
   5991     la_br &err_bad_macro_header
   5992     bne_t0,t1
   5993 
   5994     # snapshot arg_starts[0..2] / arg_ends[0..2]
   5995     la_a0 &arg_starts_ptr
   5996     ld_a0,a0,0
   5997     ld_t0,a0,0
   5998     la_a1 &ebc_arg0_start
   5999     st_t0,a1,0
   6000     la_a0 &arg_starts_ptr
   6001     ld_a0,a0,0
   6002     ld_t0,a0,8
   6003     la_a1 &ebc_then_start
   6004     st_t0,a1,0
   6005     la_a0 &arg_starts_ptr
   6006     ld_a0,a0,0
   6007     ld_t0,a0,16
   6008     la_a1 &ebc_else_start
   6009     st_t0,a1,0
   6010 
   6011     la_a0 &arg_ends_ptr
   6012     ld_a0,a0,0
   6013     ld_t0,a0,0
   6014     la_a1 &ebc_arg0_end
   6015     st_t0,a1,0
   6016     la_a0 &arg_ends_ptr
   6017     ld_a0,a0,0
   6018     ld_t0,a0,8
   6019     la_a1 &ebc_then_end
   6020     st_t0,a1,0
   6021     la_a0 &arg_ends_ptr
   6022     ld_a0,a0,0
   6023     ld_t0,a0,16
   6024     la_a1 &ebc_else_end
   6025     st_t0,a1,0
   6026 
   6027     # value = eval_expr_range(arg0_start, arg0_end)
   6028     la_a0 &ebc_arg0_start
   6029     ld_a0,a0,0
   6030     la_a1 &ebc_arg0_end
   6031     ld_a1,a1,0
   6032     la_br &eval_expr_range
   6033     call
   6034 
   6035     # if (value != 0) chosen = then; else chosen = else
   6036     la_br &ebc_select_then
   6037     bnez_a0
   6038 
   6039     # chosen = else
   6040     la_a0 &ebc_else_start
   6041     ld_t0,a0,0
   6042     la_a1 &ebc_arg0_start
   6043     st_t0,a1,0
   6044     la_a0 &ebc_else_end
   6045     ld_t0,a0,0
   6046     la_a1 &ebc_arg0_end
   6047     st_t0,a1,0
   6048     la_br &ebc_select_after_pick
   6049     b
   6050 
   6051 :ebc_select_then
   6052     # chosen = then
   6053     la_a0 &ebc_then_start
   6054     ld_t0,a0,0
   6055     la_a1 &ebc_arg0_start
   6056     st_t0,a1,0
   6057     la_a0 &ebc_then_end
   6058     ld_t0,a0,0
   6059     la_a1 &ebc_arg0_end
   6060     st_t0,a1,0
   6061 
   6062 :ebc_select_after_pick
   6063     # stream->pos = ebc_call_end_pos; stream->line_start = 0
   6064     la_a0 &ebc_stream
   6065     ld_a0,a0,0
   6066     la_a1 &ebc_call_end_pos
   6067     ld_t0,a1,0
   6068     st_t0,a0,16
   6069     li_t1 %0 %0
   6070     st_t1,a0,24
   6071 
   6072     # if (chosen_start == chosen_end) return
   6073     la_a0 &ebc_arg0_start
   6074     ld_t0,a0,0
   6075     la_a1 &ebc_arg0_end
   6076     ld_t1,a1,0
   6077     la_br &ebc_select_done
   6078     beq_t0,t1
   6079 
   6080     # mark = pool_used
   6081     la_a0 &pool_used
   6082     ld_t0,a0,0
   6083     la_a1 &ebc_mark
   6084     st_t0,a1,0
   6085 
   6086     # copy_span_to_pool(chosen_start, chosen_end)
   6087     la_a0 &ebc_arg0_start
   6088     ld_a0,a0,0
   6089     la_a1 &ebc_arg0_end
   6090     ld_a1,a1,0
   6091     la_br &copy_span_to_pool
   6092     call
   6093 
   6094     # push_pool_stream_from_mark(mark)
   6095     la_a0 &ebc_mark
   6096     ld_a0,a0,0
   6097     la_br &push_pool_stream_from_mark
   6098     call
   6099 
   6100 :ebc_select_done
   6101     eret
   6102 
   6103 ## %str(IDENT): stringify a single WORD argument into a TOK_STRING literal.
   6104 ## Validation: arg_count == 1, arg span length == 1 token, and that token's
   6105 ## kind is TOK_WORD. Output: a freshly-allocated text span built as
   6106 ## `"` + arg.text + `"` (len = arg.text.len + 2) and a synthesized TOK_STRING
   6107 ## pointing at it. Stream pos advances to call_end_pos; line_start = 0.
   6108 :ebc_str
   6109     # require arg_count == 1
   6110     la_a0 &arg_count
   6111     ld_t0,a0,0
   6112     li_t1 %1 %0
   6113     la_br &err_bad_macro_header
   6114     bne_t0,t1
   6115 
   6116     # snapshot arg_starts[0] / arg_ends[0]
   6117     la_a0 &arg_starts_ptr
   6118     ld_a0,a0,0
   6119     ld_t0,a0,0
   6120     la_a1 &ebc_arg0_start
   6121     st_t0,a1,0
   6122     la_a0 &arg_ends_ptr
   6123     ld_a0,a0,0
   6124     ld_t0,a0,0
   6125     la_a1 &ebc_arg0_end
   6126     st_t0,a1,0
   6127 
   6128     # require arg0_end - arg0_start == 32 (exactly one token)
   6129     la_a0 &ebc_arg0_start
   6130     ld_t0,a0,0
   6131     la_a1 &ebc_arg0_end
   6132     ld_t1,a1,0
   6133     sub_t2,t1,t0
   6134     li_a2 %32 %0
   6135     la_br &err_bad_macro_header
   6136     bne_t2,a2
   6137 
   6138     # require arg_tok->kind == TOK_WORD
   6139     ld_a3,t0,0
   6140     li_a2 TOK_WORD
   6141     la_br &err_bad_macro_header
   6142     bne_a3,a2
   6143 
   6144     # orig_len = arg_tok->text.len; out_len = orig_len + 2
   6145     # fatal if out_len > 256 (scratch cap; text_buf cap checked by append_text)
   6146     ld_t1,t0,16
   6147     la_a0 &ebc_str_orig_len
   6148     st_t1,a0,0
   6149     addi_t2,t1,2
   6150     la_a0 &ebc_str_out_len
   6151     st_t2,a0,0
   6152     li_a1 %256 %0
   6153     la_br &err_text_overflow
   6154     blt_a1,t2
   6155 
   6156     # scratch[0] = '"'
   6157     la_t2 &ebc_str_scratch_ptr
   6158     ld_t2,t2,0
   6159     li_a3 %34 %0
   6160     sb_a3,t2,0
   6161 
   6162     # copy arg_tok->text bytes into scratch[1..1+orig_len)
   6163     #   src = arg_tok->text.ptr; i = 0
   6164     la_a0 &ebc_arg0_start
   6165     ld_a0,a0,0
   6166     ld_t0,a0,8
   6167     la_a1 &ebc_str_orig_len
   6168     ld_t1,a1,0
   6169     li_a0 %0 %0
   6170 :ebc_str_copy_loop
   6171     la_br &ebc_str_copy_done
   6172     beq_a0,t1
   6173     add_a1,t0,a0
   6174     lb_a1,a1,0
   6175     addi_a2,a0,1
   6176     add_a2,t2,a2
   6177     sb_a1,a2,0
   6178     addi_a0,a0,1
   6179     la_br &ebc_str_copy_loop
   6180     b
   6181 :ebc_str_copy_done
   6182 
   6183     # scratch[1 + orig_len] = '"'
   6184     la_t2 &ebc_str_scratch_ptr
   6185     ld_t2,t2,0
   6186     la_a1 &ebc_str_orig_len
   6187     ld_a1,a1,0
   6188     addi_a1,a1,1
   6189     add_a0,t2,a1
   6190     li_a3 %34 %0
   6191     sb_a3,a0,0
   6192 
   6193     # text_ptr = append_text(&scratch, out_len)
   6194     la_a0 &ebc_str_scratch_ptr
   6195     ld_a0,a0,0
   6196     la_a1 &ebc_str_out_len
   6197     ld_a1,a1,0
   6198     la_br &append_text
   6199     call
   6200 
   6201     # ebc_str_token = { TOK_STRING, text_ptr, out_len, tight=0 }
   6202     la_a2 &ebc_str_token
   6203     li_a3 TOK_STRING
   6204     st_a3,a2,0
   6205     st_a0,a2,8
   6206     la_a1 &ebc_str_out_len
   6207     ld_a1,a1,0
   6208     st_a1,a2,16
   6209     li_a1 %0 %0
   6210     st_a1,a2,24
   6211 
   6212     # stream->pos = ebc_call_end_pos; stream->line_start = 0
   6213     la_a0 &ebc_stream
   6214     ld_a0,a0,0
   6215     la_a1 &ebc_call_end_pos
   6216     ld_t0,a1,0
   6217     st_t0,a0,16
   6218     li_t1 %0 %0
   6219     st_t1,a0,24
   6220 
   6221     # emit_token(&ebc_str_token)
   6222     la_a0 &ebc_str_token
   6223     la_br &emit_token
   6224     call
   6225 
   6226     eret
   6227 
   6228 ## emit_string_as_bytes(a0=tok_ptr) -> void (fatal on bad escape).
   6229 ## Decode the contents of a TOK_STRING (between the surrounding quotes)
   6230 ## and emit each byte as one TOK_WORD via emit_hex_value(byte, 1). The
   6231 ## lexer accepts both "..." and '...'; this routine just strips the
   6232 ## first/last byte of text and decodes the middle. Recognised escapes:
   6233 ##   \n \t \r \0 \\ \"     and \xNN (two hex digits, case-insensitive).
   6234 ## hex2pp's parse_byte_stream coalesces the resulting space-separated
   6235 ## hex bytes back into a contiguous byte sequence at link time.
   6236 :emit_string_as_bytes
   6237     enter_0
   6238 
   6239     # require tok->text.len >= 2
   6240     ld_a1,a0,16
   6241     li_a2 %2 %0
   6242     la_br &err_bad_escape
   6243     blt_a1,a2
   6244 
   6245     # src = tok->text.ptr + 1; src_len = tok->text.len - 2
   6246     ld_a3,a0,8
   6247     addi_a3,a3,1
   6248     la_a0 &ebc_b_src_ptr
   6249     st_a3,a0,0
   6250     addi_a1,a1,neg2
   6251     la_a0 &ebc_b_src_len
   6252     st_a1,a0,0
   6253 
   6254     # ebc_b_src_i = 0
   6255     li_a0 %0 %0
   6256     la_a1 &ebc_b_src_i
   6257     st_a0,a1,0
   6258 
   6259 :ebc_b_loop
   6260     # if (src_i == src_len) done
   6261     la_a0 &ebc_b_src_i
   6262     ld_t0,a0,0
   6263     la_a1 &ebc_b_src_len
   6264     ld_t1,a1,0
   6265     la_br &ebc_b_done
   6266     beq_t0,t1
   6267 
   6268     # c = src_ptr[src_i];  src_i++
   6269     # P1 lacks lb_a3,a0,0 — bounce through a1 (mov_a1,a0; lb_a3,a1,0).
   6270     la_a0 &ebc_b_src_ptr
   6271     ld_a0,a0,0
   6272     add_a0,a0,t0
   6273     mov_a1,a0
   6274     lb_a3,a1,0
   6275     addi_t0,t0,1
   6276     la_a1 &ebc_b_src_i
   6277     st_t0,a1,0
   6278 
   6279     # if (c == '\\') -> escape path
   6280     li_a2 %92 %0
   6281     la_br &ebc_b_escape
   6282     beq_a3,a2
   6283 
   6284     # literal byte: emit_hex_value(c, 1) and reloop
   6285     mov_a0,a3
   6286     li_a1 %1 %0
   6287     la_br &emit_hex_value
   6288     call
   6289     la_br &ebc_b_loop
   6290     b
   6291 
   6292 :ebc_b_escape
   6293     # Read the escape character; require at least one byte left.
   6294     la_a0 &ebc_b_src_i
   6295     ld_t0,a0,0
   6296     la_a1 &ebc_b_src_len
   6297     ld_t1,a1,0
   6298     la_br &err_bad_escape
   6299     beq_t0,t1
   6300     la_a0 &ebc_b_src_ptr
   6301     ld_a0,a0,0
   6302     add_a0,a0,t0
   6303     mov_a1,a0
   6304     lb_a3,a1,0                     # a3 = e
   6305     addi_t0,t0,1
   6306     la_a1 &ebc_b_src_i
   6307     st_t0,a1,0
   6308 
   6309     # Single-char escapes: dispatch via beq chain (matches the existing
   6310     # proc_check_<directive> pattern). Each branch loads the resulting
   6311     # byte into a3 and falls through to ebc_b_emit_one.
   6312     li_a2 %110 %0                  # 'n'
   6313     la_br &ebc_b_esc_n
   6314     beq_a3,a2
   6315     li_a2 %116 %0                  # 't'
   6316     la_br &ebc_b_esc_t
   6317     beq_a3,a2
   6318     li_a2 %114 %0                  # 'r'
   6319     la_br &ebc_b_esc_r
   6320     beq_a3,a2
   6321     li_a2 %48 %0                   # '0'
   6322     la_br &ebc_b_esc_zero
   6323     beq_a3,a2
   6324     li_a2 %92 %0                   # '\\'
   6325     la_br &ebc_b_esc_bs
   6326     beq_a3,a2
   6327     li_a2 %34 %0                   # '"'
   6328     la_br &ebc_b_esc_dq
   6329     beq_a3,a2
   6330     li_a2 %120 %0                  # 'x'
   6331     la_br &ebc_b_esc_hex
   6332     beq_a3,a2
   6333     la_br &err_bad_escape
   6334     b
   6335 
   6336 :ebc_b_esc_n
   6337     li_a3 %10 %0                   # 0x0A
   6338     la_br &ebc_b_emit_one
   6339     b
   6340 :ebc_b_esc_t
   6341     li_a3 %9 %0                    # 0x09
   6342     la_br &ebc_b_emit_one
   6343     b
   6344 :ebc_b_esc_r
   6345     li_a3 %13 %0                   # 0x0D
   6346     la_br &ebc_b_emit_one
   6347     b
   6348 :ebc_b_esc_zero
   6349     li_a3 %0 %0                    # 0x00
   6350     la_br &ebc_b_emit_one
   6351     b
   6352 :ebc_b_esc_bs
   6353     li_a3 %92 %0                   # 0x5C
   6354     la_br &ebc_b_emit_one
   6355     b
   6356 :ebc_b_esc_dq
   6357     li_a3 %34 %0                   # 0x22
   6358     la_br &ebc_b_emit_one
   6359     b
   6360 
   6361 :ebc_b_emit_one
   6362     # Common tail for single-char escapes: emit_hex_value(a3, 1), reloop.
   6363     mov_a0,a3
   6364     li_a1 %1 %0
   6365     la_br &emit_hex_value
   6366     call
   6367     la_br &ebc_b_loop
   6368     b
   6369 
   6370 :ebc_b_esc_hex
   6371     # \xNN: require two hex chars at src[src_i], src[src_i+1].
   6372     la_a0 &ebc_b_src_i
   6373     ld_t0,a0,0
   6374     la_a1 &ebc_b_src_len
   6375     ld_t1,a1,0
   6376     sub_t2,t1,t0                   # remaining = src_len - src_i
   6377     li_a3 %2 %0
   6378     la_br &err_bad_escape
   6379     blt_t2,a3
   6380 
   6381     # hi char: src[src_i]; decode via hex_digit_table[c]; fail if 0xFF.
   6382     la_a0 &ebc_b_src_ptr
   6383     ld_a0,a0,0
   6384     add_a0,a0,t0
   6385     lb_a0,a0,0                     # a0 = hi char
   6386     la_a1 &hex_digit_table
   6387     add_a1,a1,a0
   6388     lb_a2,a1,0                     # a2 = hi digit (or 0xFF)
   6389     li_a3 %255 %0
   6390     la_br &err_bad_escape
   6391     beq_a2,a3
   6392     # Stash hi digit into ebc_b_hex_hi for the (hi << 4) | lo combine
   6393     # below — the lo-digit lookup clobbers a2.
   6394     la_a0 &ebc_b_hex_hi
   6395     st_a2,a0,0
   6396 
   6397     # advance past hi char
   6398     la_a0 &ebc_b_src_i
   6399     ld_t0,a0,0
   6400     addi_t0,t0,1
   6401     st_t0,a0,0
   6402 
   6403     # lo char: src[src_i]; decode via hex_digit_table[c]; fail if 0xFF.
   6404     la_a0 &ebc_b_src_ptr
   6405     ld_a0,a0,0
   6406     add_a0,a0,t0
   6407     lb_a0,a0,0                     # a0 = lo char
   6408     la_a1 &hex_digit_table
   6409     add_a1,a1,a0
   6410     lb_a2,a1,0                     # a2 = lo digit (or 0xFF)
   6411     li_a3 %255 %0
   6412     la_br &err_bad_escape
   6413     beq_a2,a3
   6414 
   6415     # advance past lo char
   6416     la_a0 &ebc_b_src_i
   6417     ld_t0,a0,0
   6418     addi_t0,t0,1
   6419     st_t0,a0,0
   6420 
   6421     # byte = (hi << 4) | lo. shli_a3,t0,4 puts hi<<4 in a3, then or.
   6422     la_a0 &ebc_b_hex_hi
   6423     ld_t0,a0,0
   6424     shli_a3,t0,4
   6425     or_a3,a3,a2
   6426 
   6427     la_br &ebc_b_emit_one
   6428     b
   6429 
   6430 :ebc_b_done
   6431     eret
   6432 
   6433 ## %local(NAME): emit-time variant. expand_builtin_call has already
   6434 ## parse_args'd the call (so arg_starts/arg_ends/arg_count/call_end_pos
   6435 ## are set), but expand_local_into_pool re-parses internally so it can
   6436 ## also be invoked from eval_expr_atom where parse_args wasn't called.
   6437 ## After the helper returns, advance the stream past the call and push
   6438 ## the body slice as a fresh stream for rescan.
   6439 :ebc_local
   6440     # call_tok = stream->pos; limit = stream->end
   6441     la_a0 &ebc_stream
   6442     ld_a0,a0,0
   6443     ld_t0,a0,16                    # call_tok
   6444     ld_t1,a0,8                     # limit
   6445     mov_a0,t0
   6446     mov_a1,t1
   6447     la_br &expand_local_into_pool
   6448     call
   6449 
   6450     # stream->pos = elp_after; stream->line_start = 0
   6451     la_a0 &ebc_stream
   6452     ld_a0,a0,0
   6453     la_a1 &elp_after
   6454     ld_t0,a1,0
   6455     st_t0,a0,16
   6456     li_t1 %0 %0
   6457     st_t1,a0,24
   6458 
   6459     # push_pool_stream_from_mark(elp_mark)
   6460     la_a0 &elp_mark
   6461     ld_a0,a0,0
   6462     la_br &push_pool_stream_from_mark
   6463     call
   6464 
   6465     eret
   6466 
   6467 ## --- Error paths -------------------------------------------------------------
   6468 ## Each err_* loads a (msg, len) pair for fatal; fatal writes "m1pp: <msg>\n"
   6469 ## to stderr and exits 1. Error labels are branched to from range/overflow
   6470 ## checks throughout the code.
   6471 
   6472 :err_usage
   6473     la_a0 &msg_usage
   6474     la_br &fatal
   6475     b
   6476 :err_open_input
   6477     la_a0 &msg_open_input
   6478     la_br &fatal
   6479     b
   6480 :err_read
   6481     la_a0 &msg_read
   6482     la_br &fatal
   6483     b
   6484 :err_input_too_big
   6485     la_a0 &msg_input_too_big
   6486     la_br &fatal
   6487     b
   6488 :err_open_output
   6489     la_a0 &msg_open_output
   6490     la_br &fatal
   6491     b
   6492 :err_write
   6493     la_a0 &msg_write
   6494     la_br &fatal
   6495     b
   6496 :err_text_overflow
   6497     la_a0 &msg_text_overflow
   6498     la_br &fatal
   6499     b
   6500 :err_token_overflow
   6501     la_a0 &msg_token_overflow
   6502     la_br &fatal
   6503     b
   6504 :err_output_overflow
   6505     la_a0 &msg_output_overflow
   6506     la_br &fatal
   6507     b
   6508 :err_unterminated_macro
   6509     la_a0 &msg_unterminated_macro
   6510     la_br &fatal
   6511     b
   6512 :err_bad_macro_header
   6513     la_a0 &msg_bad_macro_header
   6514     la_br &fatal
   6515     b
   6516 :err_too_many_macros
   6517     la_a0 &msg_too_many_macros
   6518     la_br &fatal
   6519     b
   6520 :err_macro_body_overflow
   6521     la_a0 &msg_macro_body_overflow
   6522     la_br &fatal
   6523     b
   6524 :err_unbalanced_braces
   6525     la_a0 &msg_unbalanced_braces
   6526     la_br &fatal
   6527     b
   6528 :err_bad_directive
   6529     la_a0 &msg_bad_directive
   6530     la_br &fatal
   6531     b
   6532 :err_unterminated_directive
   6533     la_a0 &msg_unterminated_directive
   6534     la_br &fatal
   6535     b
   6536 :err_bad_escape
   6537     la_a0 &msg_bad_escape
   6538     la_br &fatal
   6539     b
   6540 :err_bad_frame_header
   6541     la_a0 &msg_bad_frame_header
   6542     la_br &fatal
   6543     b
   6544 :err_frame_already_active
   6545     la_a0 &msg_frame_already_active
   6546     la_br &fatal
   6547     b
   6548 :err_frame_underflow
   6549     la_a0 &msg_frame_underflow
   6550     la_br &fatal
   6551     b
   6552 :err_frame_not_closed
   6553     la_a0 &msg_frame_not_closed
   6554     la_br &fatal
   6555     b
   6556 :err_local_outside_frame
   6557     la_a0 &msg_local_outside_frame
   6558     la_br &fatal
   6559     b
   6560 :err_unknown_local
   6561     la_a0 &msg_unknown_local
   6562     la_br &fatal
   6563     b
   6564 :err_local_name_too_long
   6565     la_a0 &msg_local_name_too_long
   6566     la_br &fatal
   6567     b
   6568 
   6569 ## fatal(a0=msg_ptr): writes "m1pp: <msg>\n" to stderr and exits 1.
   6570 ## Length is computed inline via a strlen loop (messages are NUL-terminated).
   6571 ## Reached by unconditional branch from any err_* stub, so no frame is required.
   6572 :fatal
   6573     # Stash msg_ptr; compute len inline into err_saved_len.
   6574     la_a1 &err_saved_msg
   6575     st_a0,a1,0
   6576     li_t0 %0 %0
   6577 :fatal_strlen
   6578     add_t1,a0,t0
   6579     lb_t1,t1,0
   6580     la_br &fatal_strlen_done
   6581     beqz_t1
   6582     addi_t0,t0,1
   6583     la_br &fatal_strlen
   6584     b
   6585 :fatal_strlen_done
   6586     la_a1 &err_saved_len
   6587     st_t0,a1,0
   6588 
   6589     # write(2, "m1pp:", 5)
   6590     li_a0 sys_write
   6591     li_a1 %2 %0
   6592     la_a2 &msg_prefix
   6593     li_a3 %5 %0
   6594     syscall
   6595 
   6596     # write(2, msg, len)
   6597     la_a0 &err_saved_msg
   6598     ld_a2,a0,0
   6599     la_a0 &err_saved_len
   6600     ld_a3,a0,0
   6601     li_a0 sys_write
   6602     li_a1 %2 %0
   6603     syscall
   6604 
   6605     # write(2, "\n", 1)
   6606     li_a0 sys_write
   6607     li_a1 %2 %0
   6608     la_a2 &msg_newline
   6609     li_a3 %1 %0
   6610     syscall
   6611 
   6612     # exit(1)
   6613     li_a0 sys_exit
   6614     li_a1 %1 %0
   6615     syscall
   6616 
   6617 ## Sentinel: marks the boundary between executable text and rodata. Read by
   6618 ## scripts/disasm-elf.sh (via scripts/m1-symbols.py) to bound disassembly
   6619 ## so trailing strings don't decode as bogus instructions.
   6620 :_text_end
   6621 
   6622 ## --- Rodata: const tokens (for tok_eq_const) and fatal messages --------------
   6623 
   6624 :const_macro "%macro"
   6625 :const_endm "%endm"
   6626 :const_paste "##"
   6627 :const_lparen "("
   6628 :const_rparen ")"
   6629 :const_comma ","
   6630 :const_lbrace "{"
   6631 :const_rbrace "}"
   6632 :const_bang "!"
   6633 :const_at "@"
   6634 :const_pct "%"
   6635 :const_dlr "$"
   6636 :const_select "%select"
   6637 :const_str "%str"
   6638 :const_struct "%struct"
   6639 :const_enum "%enum"
   6640 :const_size "SIZE"
   6641 :const_count "COUNT"
   6642 :const_frame "%frame"
   6643 :const_endframe "%endframe"
   6644 :const_local "%local"
   6645 :const_bytes "%bytes"
   6646 :const_reset_output "%reset-output"
   6647 ## Suffix appended to the frame name when looking up <frame>_FRAME.<field>.
   6648 :const_frame_suffix "_FRAME."
   6649 
   6650 ## Operator strings for expr_op_code. Each is a raw byte literal; lengths
   6651 ## are passed separately to tok_eq_const. "<=" must be tested before "<"
   6652 ## so the longer match wins; same for ">=" before ">".
   6653 :op_plus "+"
   6654 :op_minus "-"
   6655 :op_star "*"
   6656 :op_slash "/"
   6657 :op_percent "%"
   6658 :op_shl "<<"
   6659 :op_shr ">>"
   6660 :op_amp "&"
   6661 :op_bar "|"
   6662 :op_caret "^"
   6663 :op_tilde "~"
   6664 :op_eq "="
   6665 :op_ne "!="
   6666 :op_lt "<"
   6667 :op_le "<="
   6668 :op_gt ">"
   6669 :op_ge ">="
   6670 :op_strlen "strlen"
   6671 
   6672 ## Nibble-to-hex lookup table for emit_hex_value.
   6673 :hex_chars "0123456789ABCDEF"
   6674 
   6675 ## 256-byte hex-digit lookup table for %bytes(\xNN). Indexed by source
   6676 ## byte; value is the digit (0..15) for '0'..'9'/'a'..'f'/'A'..'F', or
   6677 ## 0xFF for any other input. The escape decoder reads two source bytes
   6678 ## and combines (hi << 4) | lo into the emitted byte; either lookup
   6679 ## returning 0xFF triggers err_bad_escape.
   6680 :hex_digit_table
   6681 ## 0x00-0x1F: all invalid
   6682 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'
   6683 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'
   6684 ## 0x20-0x2F: invalid
   6685 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'
   6686 ## 0x30-0x39 = '0'..'9' -> 0..9; 0x3A-0x3F invalid
   6687 '00010203040506070809FFFFFFFFFFFF'
   6688 ## 0x40 invalid; 0x41-0x46 = 'A'..'F' -> 10..15; 0x47-0x5F invalid
   6689 'FF0A0B0C0D0E0FFFFFFFFFFFFFFFFFFF'
   6690 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'
   6691 ## 0x60 invalid; 0x61-0x66 = 'a'..'f' -> 10..15; 0x67-0x7F invalid
   6692 'FF0A0B0C0D0E0FFFFFFFFFFFFFFFFFFF'
   6693 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'
   6694 ## 0x80-0xFF: all invalid
   6695 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'
   6696 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'
   6697 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'
   6698 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'
   6699 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'
   6700 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'
   6701 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'
   6702 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'
   6703 
   6704 ## 256-byte char-class table for lex_loop / lex_word_scan. Indexed by the
   6705 ## source byte `c`; value is the class code dispatched by lex_loop:
   6706 ##   0  WORD (default; word_scan continues through this byte)
   6707 ##   1  SKIP (non-newline whitespace: 0x09 tab, 0x0B-0x0D vt/ff/cr, 0x20 sp)
   6708 ##   2  NEWLINE (0x0A)
   6709 ##   3  STRING (0x22 ", 0x27 ')
   6710 ##   4  HASH (0x23 #)
   6711 ##   5  COMMENT (0x3B ;)
   6712 ##   6  LPAREN (0x28 ()
   6713 ##   7  RPAREN (0x29 ))
   6714 ##   8  COMMA  (0x2C ,)
   6715 ##   9  LBRACE (0x7B {)
   6716 ##  10  RBRACE (0x7D })
   6717 ##  11  NUL (0x00 — lex_loop fall-through to lex_done)
   6718 :lex_char_class
   6719 ## bytes 0x00-0x1F: NUL=11, \t=1, \n=2, \v/\f/\r=1, rest=0
   6720 '0B000000000000000001020101010000'
   6721 '00000000000000000000000000000000'
   6722 ## bytes 0x20-0x3F: sp=1, "=3, #=4, '=3, (=6, )=7, ,=8, ;=5
   6723 '01000304000000030607000008000000'
   6724 '00000000000000000000000500000000'
   6725 ## bytes 0x40-0x7F: {=9 (0x7B), }=10 (0x7D)
   6726 '00000000000000000000000000000000'
   6727 '00000000000000000000000000000000'
   6728 '00000000000000000000000000000000'
   6729 '000000000000000000000009000A0000'
   6730 ## bytes 0x80-0xFF: all 0 (word)
   6731 '00000000000000000000000000000000'
   6732 '00000000000000000000000000000000'
   6733 '00000000000000000000000000000000'
   6734 '00000000000000000000000000000000'
   6735 '00000000000000000000000000000000'
   6736 '00000000000000000000000000000000'
   6737 '00000000000000000000000000000000'
   6738 '00000000000000000000000000000000'
   6739 
   6740 ## BSS pointer-slot init table (for p1_main's bss_init_loop).
   6741 ## Each entry: 8-byte slot ptr (&label + 4 pad) + 8-byte OFF_* constant.
   6742 ## Walked linearly; order is irrelevant.
   6743 :bss_init_tbl
   6744 &paste_scratch_ptr ZERO4 OFF_paste_scratch
   6745 &local_label_scratch_ptr ZERO4 OFF_local_label_scratch
   6746 &df_name_scratch_ptr ZERO4 OFF_df_name_scratch
   6747 &ebc_str_scratch_ptr ZERO4 OFF_ebc_str_scratch
   6748 &arg_starts_ptr ZERO4 OFF_arg_starts
   6749 &arg_ends_ptr ZERO4 OFF_arg_ends
   6750 &input_buf_ptr ZERO4 OFF_input_buf
   6751 &output_buf_ptr ZERO4 OFF_output_buf
   6752 &text_buf_ptr ZERO4 OFF_text_buf
   6753 &source_tokens_ptr ZERO4 OFF_source_tokens
   6754 &macros_ptr ZERO4 OFF_macros
   6755 &macro_body_tokens_ptr ZERO4 OFF_macro_body_tokens
   6756 &streams_ptr ZERO4 OFF_streams
   6757 &expand_pool_ptr ZERO4 OFF_expand_pool
   6758 &expr_frames_ptr ZERO4 OFF_expr_frames
   6759 &local_lookup_scratch_ptr ZERO4 OFF_local_lookup_scratch
   6760 &macro_body_param_idx_ptr ZERO4 OFF_macro_body_param_idx
   6761 &macro_body_is_local_label_ptr ZERO4 OFF_macro_body_is_local_label
   6762 :bss_init_tbl_end
   6763 
   6764 :msg_prefix "m1pp: "
   6765 :msg_newline "
   6766 "
   6767 ## All err_* messages below are NUL-terminated (trailing '00'); fatal uses an
   6768 ## inline strlen loop rather than a caller-supplied length.
   6769 :msg_usage "usage: m1pp input.M1 output.M1" '00'
   6770 :msg_open_input "failed to open input file" '00'
   6771 :msg_read "failed to read input" '00'
   6772 :msg_input_too_big "input file too large" '00'
   6773 :msg_open_output "failed to open output file" '00'
   6774 :msg_write "failed to write output" '00'
   6775 :msg_text_overflow "text buffer overflow" '00'
   6776 :msg_token_overflow "token buffer overflow" '00'
   6777 :msg_output_overflow "output buffer overflow" '00'
   6778 :msg_unterminated_macro "unterminated %macro definition" '00'
   6779 :msg_bad_macro_header "bad macro header" '00'
   6780 :msg_too_many_macros "too many macros" '00'
   6781 :msg_macro_body_overflow "macro body overflow" '00'
   6782 :msg_unbalanced_braces "unbalanced braces" '00'
   6783 :msg_bad_directive "bad %struct/%enum directive" '00'
   6784 :msg_unterminated_directive "unterminated %struct/%enum directive" '00'
   6785 :msg_bad_escape "bad escape in %bytes" '00'
   6786 :msg_bad_frame_header "bad frame header" '00'
   6787 :msg_frame_already_active "frame already active" '00'
   6788 :msg_frame_underflow "frame underflow" '00'
   6789 :msg_frame_not_closed "frame not closed" '00'
   6790 :msg_local_outside_frame "local outside frame" '00'
   6791 :msg_unknown_local "unknown local" '00'
   6792 :msg_local_name_too_long "local name too long" '00'
   6793 
   6794 ## --- BSS ---------------------------------------------------------------------
   6795 ## Placed before :ELF_end so filesz/memsz (which this ELF header sets equal)
   6796 ## covers the whole zero-initialized region. Bloats the file by the BSS size,
   6797 ## but avoids a custom ELF header.
   6798 ##
   6799 ## Layout: scalars (pointers, counters, lexer/processor state), then the
   6800 ## four arenas — input_buf, output_buf, text_buf, source_tokens — whose
   6801 ## sizes match the CAP constants above.
   6802 
   6803 ## Scalars (each 8 bytes).
   6804 :input_fd
   6805 ZERO8
   6806 :input_len
   6807 ZERO8
   6808 :output_fd
   6809 ZERO8
   6810 :output_used
   6811 ZERO8
   6812 :output_written
   6813 ZERO8
   6814 :output_need_space
   6815 ZERO8
   6816 :input_path
   6817 ZERO8
   6818 :output_path
   6819 ZERO8
   6820 :text_used
   6821 ZERO8
   6822 :source_end
   6823 ZERO8
   6824 :lex_ptr
   6825 ZERO8
   6826 :lex_start
   6827 ZERO8
   6828 :lex_quote
   6829 ZERO8
   6830 :lex_punct_kind
   6831 ZERO8
   6832 :lex_saw_separator
   6833 ZERO8
   6834 :proc_pos
   6835 ZERO8
   6836 :proc_line_start
   6837 ZERO8
   6838 ## proc_has_paren — set per iteration of proc_loop to 1 when the next
   6839 ## token is a tight TOK_LPAREN (i.e. the current token is a paren-call
   6840 ## form). Read by directive/builtin sub-handlers that gate on paren form
   6841 ## (%select, %str, %bytes, %local, !@$% arith, user-macro paren call).
   6842 :proc_has_paren
   6843 ZERO8
   6844 :macros_end
   6845 ZERO8
   6846 :macro_body_end
   6847 ZERO8
   6848 :def_m_ptr
   6849 ZERO8
   6850 :def_param_ptr
   6851 ZERO8
   6852 :def_body_line_start
   6853 ZERO8
   6854 ## def_body_meta_idx — slot index of the body token currently being copied
   6855 ## by def_body_copy, i.e. (macro_body_end - macro_body_tokens) / 32. Used
   6856 ## as the parallel-array index for macro_body_param_idx[] / _is_local_label[]
   6857 ## across the find_param call, which clobbers caller-saved registers.
   6858 :def_body_meta_idx
   6859 ZERO8
   6860 :pf_stream_end
   6861 ZERO8
   6862 
   6863 ## --- Frame state -------------------------------------------------------------
   6864 ## Single-slot "current frame" used by %local. current_frame_ptr/_len point
   6865 ## into stable text memory (input_buf or text_buf), borrowed from the WORD
   6866 ## token that named the frame. frame_active is 0 / 1.
   6867 ## elp_after / elp_mark are expand_local_into_pool's outputs (mirroring
   6868 ## emt_after_pos / emt_mark for expand_macro_tokens).
   6869 :current_frame_ptr
   6870 ZERO8
   6871 :current_frame_len
   6872 ZERO8
   6873 :frame_active
   6874 ZERO8
   6875 :elp_after
   6876 ZERO8
   6877 :elp_mark
   6878 ZERO8
   6879 :elp_arg_ptr
   6880 ZERO8
   6881 :elp_arg_len
   6882 ZERO8
   6883 :elp_name_len
   6884 ZERO8
   6885 :err_saved_msg
   6886 ZERO8
   6887 :err_saved_len
   6888 ZERO8
   6889 
   6890 ## Stream / pool / arg / expression scalars. Each is one u64 (ZERO8).
   6891 ## pool_used      — byte offset into expand_pool (i.e. next write slot).
   6892 ## stream_top     — stream stack depth in bytes (count × 40; 0 == empty).
   6893 ## arg_count      — number of args produced by the most recent parse_args.
   6894 ## call_end_pos   — Token* one past the ')' of that call.
   6895 ## expr_frame_top — ExprFrame stack depth inside eval_expr_range.
   6896 ## emt_after_pos, emt_mark — expand_macro_tokens output slots (Token* and
   6897 ##                           byte offset into expand_pool).
   6898 ## eval_after_pos, eval_value — eval_expr_atom output slots (Token* and i64).
   6899 ##                              Callers MUST snapshot these before any nested
   6900 ##                              eval_* call that could overwrite them.
   6901 :pool_used
   6902 ZERO8
   6903 :stream_top
   6904 ZERO8
   6905 :arg_count
   6906 ZERO8
   6907 :call_end_pos
   6908 ZERO8
   6909 :expr_frame_top
   6910 ZERO8
   6911 :emt_after_pos
   6912 ZERO8
   6913 :emt_mark
   6914 ZERO8
   6915 :eval_after_pos
   6916 ZERO8
   6917 :eval_value
   6918 ZERO8
   6919 
   6920 ## Paste-pass spill slots. Both append_pasted_token and paste_pool_range
   6921 ## call other functions, so all locals must round-trip through BSS
   6922 ## across the call.
   6923 ##   paste_dst_save  — dst Token* spilled across append_text
   6924 ##   paste_left_ptr/_len, paste_right_ptr/_len — operand spans for the
   6925 ##                       byte-copy loops in append_pasted_token
   6926 ##   paste_total_len — left.len + right.len, reused after append_text
   6927 ##   paste_start     — expand_pool + mark; needed to detect "## is first"
   6928 ##                     after registers are clobbered by append_pasted_token
   6929 ##   paste_in        — current read cursor (Token*)
   6930 ##   paste_out       — current write cursor (Token*)
   6931 ##   paste_end       — exclusive end (Token*), = expand_pool + pool_used
   6932 :paste_dst_save
   6933 ZERO8
   6934 :paste_left_ptr
   6935 ZERO8
   6936 :paste_left_len
   6937 ZERO8
   6938 :paste_right_ptr
   6939 ZERO8
   6940 :paste_right_len
   6941 ZERO8
   6942 :paste_total_len
   6943 ZERO8
   6944 :paste_start
   6945 ZERO8
   6946 :paste_in
   6947 ZERO8
   6948 :paste_out
   6949 ZERO8
   6950 :paste_end
   6951 ZERO8
   6952 
   6953 ## paste_scratch — 256-byte working buffer for append_pasted_token.
   6954 ## We assemble left.text ++ right.text here, then call
   6955 ## append_text(&paste_scratch, total_len) to copy into the durable
   6956 ## text_buf arena. 256 bytes is M0's quoted-literal cap.
   6957 
   6958 ## parse_args + expand_macro_tokens + find_param spill slots (P1 has
   6959 ## no callee-save spill on enter, and find_param's inner byte compare
   6960 ## needs every caller-saved register; parse_args + expand_macro_tokens
   6961 ## carry state across iterations and nested calls). One u64 each (ZERO8).
   6962 :pa_pos
   6963 ZERO8
   6964 :pa_arg_start
   6965 ZERO8
   6966 :pa_depth
   6967 ZERO8
   6968 :pa_arg_index
   6969 ZERO8
   6970 :pa_limit
   6971 ZERO8
   6972 :pa_brace_depth
   6973 ZERO8
   6974 ## args_have_paste — sticky 0/1 set by parse_args when the call's argument
   6975 ## span contains TOK_PASTE. expand_macro_tokens snapshots it into
   6976 ## emt_saw_arg_paste right after parse_args, then ORs it with
   6977 ## macro->has_paste to decide whether to run paste_pool_range. Lets us
   6978 ## skip the pool sweep when neither body nor args contribute a `##`.
   6979 :args_have_paste
   6980 ZERO8
   6981 :emt_call_tok
   6982 ZERO8
   6983 :emt_limit
   6984 ZERO8
   6985 :emt_macro
   6986 ZERO8
   6987 :emt_saw_arg_paste
   6988 ZERO8
   6989 ## emt_cached_param_idx — body token's param index (0 = not a param) read
   6990 ## once from macro_body_param_idx[] at the top of emt_loop, then reused in
   6991 ## emt_do_substitute_paste / _plain instead of re-running find_param.
   6992 :emt_cached_param_idx
   6993 ZERO8
   6994 :emt_body_pos
   6995 ZERO8
   6996 :emt_body_end
   6997 ZERO8
   6998 :emt_body_start
   6999 ZERO8
   7000 
   7001 ## Local-label rewrite. next_expansion_id is the monotonic counter
   7002 ## (never reset); emt_expansion_id snapshots it at the start of each
   7003 ## expand_macro_tokens call so nested-call BSS reuse is safe.
   7004 ## ll_* slots hold body-token span + derived sizes while building the
   7005 ## renamed text in local_label_scratch.
   7006 :next_expansion_id
   7007 ZERO8
   7008 :emt_expansion_id
   7009 ZERO8
   7010 :ll_src_ptr
   7011 ZERO8
   7012 :ll_src_len
   7013 ZERO8
   7014 :ll_tail_len
   7015 ZERO8
   7016 :ll_digit_count
   7017 ZERO8
   7018 :ll_digit_cursor
   7019 ZERO8
   7020 :ll_total_len
   7021 ZERO8
   7022 
   7023 ## local_label_digits: 24-byte reverse-fill scratch for the decimal
   7024 ## rendering of emt_expansion_id (fits any u64 value).
   7025 :local_label_digits
   7026 ZERO8 ZERO8 ZERO8
   7027 
   7028 ## local_label_scratch: 128-byte working buffer for the renamed text
   7029 ## (sigil + tail + "__" + digits) before it's copied into text_buf via
   7030 ## append_text. Caps the combined tail + digit length at ~125 bytes,
   7031 ## which is ample for any realistic local-label name.
   7032 
   7033 ## %struct / %enum scratch. define_fielded calls append_text twice
   7034 ## per synthesized macro, so every piece of state that must survive a call
   7035 ## lives here rather than in a register.
   7036 ##   df_stride               — 8 for %struct, 1 for %enum
   7037 ##   df_total_name_ptr/_len  — "SIZE" (4) for struct, "COUNT" (5) for enum
   7038 ##   df_base_ptr/_len        — directive's NAME token span
   7039 ##   df_index                — running field index, 0..N
   7040 ##   df_suffix_ptr/_len      — current synthesized field suffix
   7041 ##   df_value                — index * stride for this macro's body
   7042 ##   df_name_len             — base_len + 1 + suffix_len
   7043 ##   df_digit_count/_cursor  — df_render_decimal output
   7044 :df_stride
   7045 ZERO8
   7046 :df_total_name_ptr
   7047 ZERO8
   7048 :df_total_name_len
   7049 ZERO8
   7050 :df_base_ptr
   7051 ZERO8
   7052 :df_base_len
   7053 ZERO8
   7054 :df_index
   7055 ZERO8
   7056 :df_suffix_ptr
   7057 ZERO8
   7058 :df_suffix_len
   7059 ZERO8
   7060 :df_value
   7061 ZERO8
   7062 :df_name_len
   7063 ZERO8
   7064 :df_digit_count
   7065 ZERO8
   7066 :df_digit_cursor
   7067 ZERO8
   7068 
   7069 ## df_name_scratch: 256-byte working buffer for "BASE.SUFFIX" before
   7070 ## append_text copies it to text_buf. 256 B matches paste_scratch /
   7071 ## ebc_str_scratch; df_emit_field asserts nothing explicit, but realistic
   7072 ## struct/enum names stay well under 128 chars.
   7073 
   7074 ## df_digit_scratch: 24-byte reverse-fill buffer for the decimal rendering
   7075 ## of df_value (any u64 fits).
   7076 :df_digit_scratch
   7077 ZERO8 ZERO8 ZERO8
   7078 
   7079 :fp_macro
   7080 ZERO8
   7081 :fp_tok
   7082 ZERO8
   7083 :fp_pcount
   7084 ZERO8
   7085 :fp_idx
   7086 ZERO8
   7087 
   7088 ## Expression-evaluator scratch slots. expr_op_code spills its tok
   7089 ## argument to eoc_tok across tok_eq_const calls. apply_expr_op spills
   7090 ## op/args/argc and uses acc/i as the accumulator and loop induction var
   7091 ## inside the variadic folds.
   7092 :eoc_tok
   7093 ZERO8
   7094 :aeo_op
   7095 ZERO8
   7096 :aeo_args
   7097 ZERO8
   7098 :aeo_argc
   7099 ZERO8
   7100 :aeo_acc
   7101 ZERO8
   7102 :aeo_i
   7103 ZERO8
   7104 
   7105 ## Builtin scratch.
   7106 ## emit_hex_value: ehv_value/bytes hold the args; ehv_scratch is a 24-byte
   7107 ## buffer (max 16 chars used: 16 hex chars for an 8-byte $-emit; rounded
   7108 ## up to keep the next slot 8-byte aligned); ehv_token is a synthesized
   7109 ## 32-byte Token { kind, text_ptr, text_len, tight }.
   7110 :ehv_value
   7111 ZERO8
   7112 :ehv_bytes
   7113 ZERO8
   7114 :ehv_scratch
   7115 ZERO8 ZERO8 ZERO8
   7116 :ehv_token
   7117 ZERO8 ZERO8 ZERO8 ZERO8
   7118 
   7119 ## expand_builtin_call: snapshots the stream pointer, the post-call resume
   7120 ## position, the byte count for !@%$, the eval_expr_range result, the chosen
   7121 ## arg span (start/end), the unchosen-side spans for %select, and the
   7122 ## pool mark used to push the chosen-stream slice.
   7123 :ebc_stream
   7124 ZERO8
   7125 :ebc_call_end_pos
   7126 ZERO8
   7127 :ebc_bytes
   7128 ZERO8
   7129 :ebc_value
   7130 ZERO8
   7131 :ebc_arg0_start
   7132 ZERO8
   7133 :ebc_arg0_end
   7134 ZERO8
   7135 :ebc_then_start
   7136 ZERO8
   7137 :ebc_then_end
   7138 ZERO8
   7139 :ebc_else_start
   7140 ZERO8
   7141 :ebc_else_end
   7142 ZERO8
   7143 :ebc_mark
   7144 ZERO8
   7145 
   7146 ## %str builtin scratch. ebc_str_orig_len / ebc_str_out_len spill the
   7147 ## argument text length and its +2 output length across append_text;
   7148 ## ebc_str_token is the synthesized TOK_STRING { kind, text_ptr, text_len,
   7149 ## tight } handed to emit_token; ebc_str_scratch is a 256-byte assembly
   7150 ## buffer (matches paste_scratch / M0's quoted-literal cap).
   7151 :ebc_str_orig_len
   7152 ZERO8
   7153 :ebc_str_out_len
   7154 ZERO8
   7155 :ebc_str_token
   7156 ZERO8 ZERO8 ZERO8 ZERO8
   7157 
   7158 ## %bytes builtin scratch. ebc_b_src_ptr/_len/_i walk the input string
   7159 ## across emit_hex_value calls (which clobber every caller-saved reg).
   7160 ## ebc_b_hex_hi spills the high nibble across the second hex_digit_table
   7161 ## lookup for the low nibble. Each source byte emits independently via
   7162 ## emit_hex_value(byte, 1); hex2pp's parse_byte_stream coalesces the
   7163 ## resulting space-separated runs back into a contiguous byte stream.
   7164 :ebc_b_src_ptr
   7165 ZERO8
   7166 :ebc_b_src_len
   7167 ZERO8
   7168 :ebc_b_src_i
   7169 ZERO8
   7170 :ebc_b_hex_hi
   7171 ZERO8
   7172 
   7173 ## arg_starts[16] / arg_ends[16]: 16 × 8 = 128 bytes each, i.e. 4 ZERO32.
   7174 ## Written by parse_args; read by expand_macro_tokens and expand_builtin_call.
   7175 
   7176 ## input_buf: 8 KB (M1PP_INPUT_CAP)
   7177 
   7178 ## output_buf: 8 KB (M1PP_OUTPUT_CAP)
   7179 
   7180 ## text_buf: 1 MB (M1PP_TEXT_CAP)
   7181 
   7182 ## source_tokens (M1PP_TOKENS_END)
   7183 
   7184 ## macros: 32 records × 296 bytes = 9472 bytes (M1PP_MACROS_CAP).
   7185 ## 37 lines × 256 bytes = 9472. Each line is 8 × ZERO32 = 256 bytes.
   7186 
   7187 ## macro_body_tokens: 256 slots × 24 bytes = 6 KB (M1PP_MACRO_BODY_CAP).
   7188 ## 24 lines × 256 bytes = 6144. Source tokens are copied in 24 bytes at a
   7189 ## time as macro bodies are recorded.
   7190 
   7191 ## streams: 16 Stream records × 40 bytes = 640 bytes (M1PP_STREAM_STACK_CAP).
   7192 ## 20 ZERO32 = 2 lines of 8 + 1 line of 4.
   7193 
   7194 ## expand_pool: 256 Token slots × 24 bytes = 6144 bytes (M1PP_EXPAND_CAP).
   7195 ## 24 lines × 8 ZERO32 = 192 ZERO32.
   7196 
   7197 ## expr_frames: 16 × 144 bytes = 2304 bytes (M1PP_EXPR_FRAMES_CAP).
   7198 ## 9 lines × 8 ZERO32 = 72 ZERO32.
   7199 
   7200 ## --- BSS pointer slots (set by p1_main; one per BSS buffer) -----------------
   7201 :paste_scratch_ptr
   7202 ZERO8
   7203 :local_label_scratch_ptr
   7204 ZERO8
   7205 :df_name_scratch_ptr
   7206 ZERO8
   7207 :ebc_str_scratch_ptr
   7208 ZERO8
   7209 :arg_starts_ptr
   7210 ZERO8
   7211 :arg_ends_ptr
   7212 ZERO8
   7213 :input_buf_ptr
   7214 ZERO8
   7215 :output_buf_ptr
   7216 ZERO8
   7217 :text_buf_ptr
   7218 ZERO8
   7219 :source_tokens_ptr
   7220 ZERO8
   7221 :macros_ptr
   7222 ZERO8
   7223 :macro_body_tokens_ptr
   7224 ZERO8
   7225 :streams_ptr
   7226 ZERO8
   7227 :expand_pool_ptr
   7228 ZERO8
   7229 :expr_frames_ptr
   7230 ZERO8
   7231 :local_lookup_scratch_ptr
   7232 ZERO8
   7233 :macro_body_param_idx_ptr
   7234 ZERO8
   7235 :macro_body_is_local_label_ptr
   7236 ZERO8
   7237 
   7238 :ELF_end