From 0602fd959033111d74bfa77fc8e70497904a27de Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 02:34:56 +0000 Subject: [PATCH] analyzer+codegen: lift the 4-param ceiling via a direct-write calling convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the silent-drop audit. The old ABI passed every parameter through four fixed zero-page transport slots `$04-$07`, imposing a hard 4-param cap (E0506) that didn't compose with structs/arrays/u16s and fell back to "pack args into a global" workarounds whenever a function needed five things. The transport scheme also cost every non-leaf call a 4-LDA/STA spill prologue (~28 cycles, 16 bytes) to copy args out of ZP before the next nested `JSR` could clobber them. Replace it with a hybrid convention keyed on leaf-ness: - **Leaf callees** (no nested `JSR` in body, ≤4 params): unchanged. Caller stages args into `$04-$07`; body reads those slots directly for its entire lifetime. No prologue copy. Fastest path, 3-cycle ZP stores + 3-cycle ZP loads, preserves the SHA-256 leaf-primitive optimisation that motivated the original fast path. - **Non-leaf callees** (body contains a nested `JSR`, OR ≥5 params): direct-write. Caller stages each argument straight into the callee's analyzer-allocated parameter RAM slot, bypassing the transport slots entirely. No prologue copy on the callee side. Saves ~24 cycles and ~16 bytes per call vs the old transport-then-spill path, and — crucially — scales past 4 params because the per-param slots live wherever the analyzer put them rather than in a fixed ZP window. The analyzer's ceiling moves from 4 to 8. Functions with 5–8 params are silently promoted to the non-leaf convention (even if their body has no nested `JSR`), which pays the direct-write cost rather than the prologue-copy cost — still cheaper than the old ABI. Declarations with 9+ params still emit E0506. ### Implementation - `function_is_leaf` now also requires `param_count <= 4`. - `IrCodeGen::new` populates `non_leaf_param_addrs: HashMap>` — for every non-leaf function, the ordered list of addresses its parameters occupy. Callers use this to route each arg directly to the right slot. - `IrOp::Call` branches on presence in the map: non-leaf → direct- write, leaf (or absent — 0-arg case) → ZP transport. - `gen_function` no longer emits a prologue. Leaves didn't have one; non-leaves had a 4-LDA/STA copy that is now unnecessary because args arrive pre-written to the slot. - The previous `leaf_functions: HashSet` field is removed; leaf-ness is now inferred from absence-in- `non_leaf_param_addrs` at the call site. ### Tests and regressions - `eight_param_non_leaf_function_stages_every_arg_at_its_allocated_slot` compiles an 8-param function, scans PRG for a distinct `LDA #\$NN / STA ` per arg (immediates `0x11..0x88`), and asserts that STAs to the `$04-$07` range are strictly fewer than 8 — proof the old transport path is gone for this call. - `non_leaf_call_direct_writes_args_to_callee_param_slots` replaces the old `gen_function_prologue_spills_params_to_local_ram` test with a dual assertion: (a) no `LDA \$04` prologue at the callee entry, and (b) the caller-side STA lands at the analyzer-allocated param slot, not at `\$04-\$07`. - `analyze_rejects_function_with_more_than_4_params` renamed and rewritten for the new 8-param cap. - `feature_canary.ne` gains a 6-param `sum6` call (1+2+3+4+5+6 = 21) as check 8. The canary stays green (all eight checks pass), so the committed golden is unchanged. ### Blast radius - Six example ROMs change bytes (arrays_and_functions, function_chain, mmc1_banked, pong, sha256, war) because their non-leaf call sites pick up the shorter staging sequence. - Pong and war audio hashes refresh (pure layout-timing shift; no behavioural change in the 180-frame no-input window). docs/pong.gif and docs/war.gif stay byte-identical. - `examples/function_chain.ne`'s header comment updated to document the leaf vs non-leaf split it exercises. - `docs/language-guide.md` parameter-count section and E0506 entry updated to reflect the new rule. All 720 Rust tests pass; all 35 emulator goldens pass. https://claude.ai/code/session_01AoQ678uVeqpyayvWHpfDhC --- docs/language-guide.md | 4 +- examples/arrays_and_functions.nes | Bin 24592 -> 24592 bytes examples/feature_canary.ne | 22 ++ examples/feature_canary.nes | Bin 24592 -> 24592 bytes examples/function_chain.ne | 20 +- examples/function_chain.nes | Bin 24592 -> 24592 bytes examples/mmc1_banked.nes | Bin 57360 -> 57360 bytes examples/pong.nes | Bin 24592 -> 24592 bytes examples/sha256.nes | Bin 24592 -> 24592 bytes examples/war.nes | Bin 24592 -> 24592 bytes src/analyzer/mod.rs | 30 ++- src/analyzer/tests.rs | 20 +- src/codegen/ir_codegen.rs | 327 +++++++++++++++---------- src/errors/diagnostic.rs | 2 +- tests/emulator/goldens/pong.audio.hash | 2 +- tests/emulator/goldens/war.audio.hash | 2 +- tests/integration_test.rs | 91 +++++++ 17 files changed, 364 insertions(+), 156 deletions(-) diff --git a/docs/language-guide.md b/docs/language-guide.md index 0c7c122..6e336c3 100644 --- a/docs/language-guide.md +++ b/docs/language-guide.md @@ -304,7 +304,7 @@ reset_score() - **No recursion.** Both direct and indirect recursion are compile errors (`E0402`). - **Call depth limit.** The default maximum call depth is 8. Exceeding it produces error `E0401`. -- **Maximum 4 parameters per function.** The v0.1 calling convention passes parameters via four fixed zero-page slots (`$04`-`$07`). Declaring a function with 5+ parameters produces error `E0506`. Pack additional state into globals or split the function into smaller helpers. +- **Maximum 8 parameters per function.** The calling convention is hybrid: **leaf** functions (no nested `JSR` in their body) receive up to four parameters through fixed zero-page transport slots `$04`-`$07`, while **non-leaf** functions receive up to eight parameters via direct caller writes into per-function RAM spill slots (no transport, no prologue copy). Declaring a function with 9+ parameters produces error `E0506`. Declaring a leaf with 5+ parameters silently promotes it to the non-leaf convention — you pay the direct-write cost rather than the prologue-copy cost, which is still cheaper than the old transport-plus-spill path. --- @@ -1349,7 +1349,7 @@ reference NEScript variables. | E0503 | Undefined function | | E0504 | Missing start declaration | | E0505 | Multiple start declarations| -| E0506 | Function has too many parameters (max 4) | +| E0506 | Function has too many parameters (max 8) | ### Warnings (W01xx) diff --git a/examples/arrays_and_functions.nes b/examples/arrays_and_functions.nes index 4c008abe1842ff672b7f5f71572164eb701d7f77..7b0337e2070971d2beec40e38953cbc95a4f4fce 100644 GIT binary patch delta 100 zcmV-q0Gt1izyXlJ0g!wFE|Gm&0Va{yEt3@iAPOVFr5(u#kONF3lRp7n0S%L#0U-gX zlfMBR7#O7yg&C!Sg&L)Vg&QFBz=adZ0q_G%+>`SGI01FDECSv=lfVKH1x~^Mz=*RC GKmj1=JtJ`d delta 112 zcmV-$0FVEWzyXlJ0g!wFK9PM|0XC7?Ee`~R7^MY;8Knk=8l?w?8{X0UeW#0U-gnldl0B7zCveg$1R8g$AXBg$E$?z=adZ0q_G%?33dGI01#T S90J}w1zy4cz?icSKmj0Ah9-&t diff --git a/examples/feature_canary.ne b/examples/feature_canary.ne index af9ff58..99b31fd 100644 --- a/examples/feature_canary.ne +++ b/examples/feature_canary.ne @@ -80,6 +80,22 @@ fun double_u8(x: u8) -> u8 { return x + x } +// A six-parameter non-leaf function. The call site exercises +// the direct-write calling convention — the caller stages each +// arg straight into the callee's per-param RAM slot, no +// transport through `$04-$07`. Returns the sum of all six, so +// a regression that silently drops any one (same shape as PR +// #31 but for params) knocks the result off 21 and flips the +// canary red. +fun sum6(a: u8, b: u8, c: u8, d: u8, e: u8, f: u8) -> u8 { + var tmp: u8 = a + b + tmp = tmp + c + tmp = tmp + d + tmp = tmp + e + tmp = tmp + f + return tmp +} + // ── Main state ───────────────────────────────────────────── state Main { @@ -128,6 +144,12 @@ state Main { var r: u8 = double_u8(21) if r != 42 { all_ok = 0 } + // Check 8: six-parameter non-leaf function — exercises + // the direct-write calling convention that lifts the + // old 4-param ceiling. 1+2+3+4+5+6 = 21. + var s: u8 = sum6(1, 2, 3, 4, 5, 6) + if s != 21 { all_ok = 0 } + // Drive the backdrop flip. `set_palette` schedules an // update during the next vblank, so the effect lands on // the following frame — well before the frame-180 golden diff --git a/examples/feature_canary.nes b/examples/feature_canary.nes index 9f257ce248dd09f77644077a56054575c0231528..157a2236f87ef42a1bd9fbd9d15711bc3bb32cd0 100644 GIT binary patch delta 262 zcmbPmfN=s4RWLr8SZQp3@xW4s4}VrNv@$GZKFRRG#%JDvr7RMuED1~HBvR#DRU}ds zK!hTQPy!LktttsX<&rBITcuV$nRs5x?Dqj6UutD?F9Y*R-(E)MwG7NBlP@s)2peTP03b1Gxs1fsJY^}-)TMx9#oD>DBI|Np@R0`-9W}lk} efr^Ay9@;#YQPYl1qX2V{c) delta 207 zcmbPmfN=s4RWL4?SZS=h^}tew4}VrNv@$GZKFRPw!Drrqr7RMuED1m%$(4+)QY#ls zd@W^m{{WCLwKBPvfqA8GFC+6>2IiB=7nps%gT>b}GN1GX@>maoRaLi2oU8_NMJ8J_ zYUo)XT*>%>1uVg|YNh#H7Uq>qtM;-mpEL(5iGt`j(aXVn@&u5dHF+YVuxK4vbg2~3 oLCii=53W4WDzx(8=7)@$c9SnKxv)Jr$Z#O`&}Ko0|MH9s02K>Uu>b%7 diff --git a/examples/function_chain.ne b/examples/function_chain.ne index bf9d3e9..53f708d 100644 --- a/examples/function_chain.ne +++ b/examples/function_chain.ne @@ -6,14 +6,24 @@ // // frame -> compute -> scale -> clamp -> fold -> taper // -// Each function takes its argument through the zero-page -// parameter slots ($04-$07), computes a small transform, and -// returns a value in A. The chained result is what drives the -// player sprite's X position on screen each frame. +// Each function takes its argument through the calling +// convention and returns a value in A. The chained result is +// what drives the player sprite's X position on screen each +// frame. +// +// Parameters land at a non-uniform address set: +// - The deepest callee (`taper`) is a leaf — it has no nested +// `JSR` and can receive its arg in the `$04` transport slot +// directly. Its body reads `$04` in place of a spill copy. +// - Every other function (`compute`, `scale`, `clamp`, `fold`) +// is non-leaf and uses the direct-write convention: each +// caller stages the arg straight into the callee's +// analyzer-allocated param slot before the `JSR`. No +// transport, no prologue copy. // // What this exercises end-to-end: // - Five levels of nested `JSR` without stack corruption -// - Parameter passing via `$04-$07` between callers +// - The hybrid leaf / non-leaf calling convention // - Return value propagation through A // - `fun ... -> u8 { return ... }` — the full typed-function // shape, including an early `return` inside an `if` diff --git a/examples/function_chain.nes b/examples/function_chain.nes index 2e5045e7444edcd89b735861f3c7cf610107f925..2a4a12556069345a9ee35619853d42e3fda6cb43 100644 GIT binary patch delta 161 zcmbPmfN=s4)i8!ktktRidBCUZz*3fzCq6K3VD?#aVC9K~r7Q_cg_;2 zK&!}7kyaLkc?S}fik^G{R(JEj$``G|3hO~aP+E+uRaD^^P(Zv@LLyV3Riaf);o*UV zMGPzd&1GPE0R$JmuViTDT*>%MpjBMq)qz&Ql?6*STQFX-n|y&$fVJcx!-1Kb`5hP) E04sY(aR2}S delta 168 zcmbPmfN=s4)i9<^tktRKJm}MPU@6PV6Cap1F#D`Iu<}I0QWhX;6<#XbDsoa_0<+Jd z11kkuMV5-RvM9_u0Fn}2Dtht-SpCBTD_;PG*8_z`p)^aY*itbrAmr`REZk_!Dz;RlReZ8{qqIr*!KDHg zFPT~emNIHKd=LccVgTyOJGhduRd^+9tB}Iu14{)?KKsA~)Y}FUWmZ@|S++@a^8Q9w n$!{pi7&R3>Z~>jgtiZlmw&|_yLO%|%wr=J>z^DKKRZdRa delta 209 zcmbPmfO*0J<_+^27?UT@Z_sn&Ke&|P!=IH5tqe<dR#t`U z2UZ~-03tiZonwCSxaYx5z71A8~~A7E4f06%?Ozn%-F>hu54oyhhiFDfmTsO zs~{WOqtK)a8;ydRA4XNnNSPH0RDeR#TE&4~``T;!#xJb25{cSa&5x!x4W()um7bYh zFCew5w5_v6r*;_GWh8{w({WeI3K7jMbsT zSAT=nXlvF8KYfz-(zSSUP-xKsH=;e_XEdqqQR)tG*~WE4vk;lR%}N}bv`ehnaY6eh z{9nyhIF~Jr-4+_vaqM;nWKzp(4HQA7CC+Q3174(nd)RU3y3Fi&2M?%ioMG=ep9G#y zo%x%u#kn38{(c+k5COUagaJEj*0RG^5^=GT?htbuKifffN{bBz4%uTn!B|K>i?cPZ zMLW?D82CbRL%cqnLAF zg3YFEFC}fKhJdz*_gFs3mkUYg5J{tCzMf2608_7?BGAdr__aXT_!5+AS zS+h!~^%o)X`(d^`qdN3!>J_szOWX|KcZu2X-(BJX^#J7_+Qgd_DKU6cy2jg$`91SZ z_cBX^+ei6xfkSR!*dObS$nkk* zRE^Irb|JIC{d`h5%my+GH}oWh*GHjV-MrgGWMcwi$&Fr5)3nP(e2BNp`asNHHfuOI@ys_=?iBg3%DdkS6F+0K0K+;_fZEvY>1U%dq}v$C?u|?I{4nB z9qix-(na5%P!gO6nb{8$(!{QL16r7kc~{^YN)dpYK^kE8J9}U^2coPMy$Y*E(o z?4q%x(eOtQDPlXain}hS;@Ti?YHp-YNixg}DFD<`oeV^jMwmbfZmN}Hb$S#3gG`-a zM0$aYwnLTo;ZH`$StWJBph!{1Ou2oaV)~Hbh9uvUf;tP}sN?&?WOl)9sk=vs%0Zds zIqKw{1_G=##yjZ&yeTo?Nq#rhfp-tmgB6H=Ulq_XE?~*o{_ItuSgHnz$7}CU^R{;! z?f~JCeRUXiDE(2T&}2s)1_H$?W@axcn@W*<{1^!g?oob35nW~;UrE>aINX&l@3+=0xFSc{H=)ddiR8dqh5T5c^F?TU zFxI1HeRa}d(}F`TKLR5cfRU@qlbiveL7mhs59wcIANVSZ&rNU|>s9>c0d8dqTX-o7 zmm~b;7(@QbuDJ<*I@YHg?n(#9ga$fzk*emsiTOfuepHSicY_hF6 zd793ea%%mdkAP|^F9#m`6-4S;H0PPdr$h(cj4yvEPS7o|d>a4ZLverL3H#qL1+kx?rSGgj6%XLB{a{di?Km+2E9IAt0#;5XfsEz7+%++U zuiwJwZ{vT(_!46akR+x!rn~VO`Q9r5z9l3)%}Z46zna3!hVh*-{GEw33VY(XHJXRHUZ&EAbf2a+6Z4nh6{um5a_aWT3w`SdLgf>X{bEHk!jda@*7GaPE1204-w>G z8gTFt0LN;Ux}`HFeKHD$Ux`Xz657JcP>C5C-A5*tz_Ix`r9v7wb_cuxUnUk0Oqx z5yw&p6Vj5s^H`gIZu^zj^ujf53jv#lyg$RN{F)_>S>u6KOXg;x+*NTCE(lk}mbyBJ zp0PTthr|Zfu=pE!>t{g=mjQRsZ0@8!a%<-5>x+vu?Z;Q&F4(`4?GEgAZoXr?`N^GM H`a}3XHh!jU delta 2856 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 V#XZ#HKk8UnvR%J8yEP)9e*ykt`Cb43 delta 2080 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 BYJ~s* delta 3438 zcma)8Yiv{39ryM1E5=S3gDf}?P7+e$5SUv6i5P0rjxYq52+^)W+K0KcQwY(-Q>a_% zoVFa1YD5Fwec0M=Z9kk%ClZX2BDIa^h}}4GAb!M2=%lpMKFqWg8Xh0$v{qujbME!E z-7s~M<$M0W^M9Y;d7QhZoWt*wGeGV9qw_D-DO=C~zD4=Z`469K9h+vh{8!vYZA?wr z(yI2I>j?#)!I^{|pWh}EDn9Gx^u5YUs)IhNruTs^2# z2e~$$+pcv);jS)>zaG{zQFl(&4o6v!si8)5J=)WzhC+8h>j!K{!k@Qa3TbBqzLV`t zIAHWA{dId_^Dm>h9ong=@V1NXN=#wG9V6c#4?aeB8y}7BPILga$Czdg^v~_hJ?}}Q zUe=okV0J@{2Wo!OJ95>g(wAB92z&W=9cr#u^Tpt~h6m_sd;|E_;#(IcC!*v^lz8-g z>bk0vF|LH!Qxi`jNmV;NI;7UxLk@a?Iq9=3n6Twl?Y#+hfI8T-)JZOWs7+ZCYo#u7 zCKl$8>LFLlP6zFS(Lv^-{Rnf2_0s`1knqB5O`HvMedeVDxkH*7XMJ#XkUH5Rm?tJ! zUroQqL=Et+WVjPbqJkn4W)I1=(I?s3ntlgetG$L%*Jl{9b#msyBx?~f zE!sQ!kh}fqe@t??;N5Z+SuJ$Lf`2*5(-tZfy09{E1MS2yJ9*5(;SPz7BN)NC0*K)z|gg-ffY60^h*e%?f{f zJ>JfrLyZKLho*SgD3{yiLh*y9Xpn~=F-2o>`17Wy8p=ba=rY7}<+i_(;sGc38Pji> z)5fy${SZNW(H78N1V%#PxWu0aj`~d;VXc`V%!pJC3l5Ql^$K67Hh4pp@-6@}$wev{ zO6S-oqIgx?Hp7&lC%=icnZ0=9M!b!CX*<-`m#xZK-Ev@4;MiR5nLISMx~J?)} z^=B#{iKHf3lWh626eDu*RgyIajp5&uI5`}AO0JaIySqsYmRH#5E9{lLO6sRIPl`1s zRwAA!CB*w#coa0>k|KlH+SvUqaPZm$w3b6_Qwrgm(6DBxMxZNpz?GEb#31%;Jt{pD zV~sMIKft${%uiFYO=dD4xsbm|F;6iWZ8#;8EaaxWA;e;;1Jl~&LaLWj1fmgNMf^Ko zkN?2bZ>Mn56;em$4p_FOzm~aVv?!#(D5N<-Q1!Yrcb1wuVfsXdMe60T1YL1Dp>_v` zPO?8nJ7o`FO!K|g8T6`2=wE}-{H951?lRo6PT9zdX&$l+-ZTwH$_(6=fh`|VwZ}4S zwe0n+G}#x$CI}T-^KrVcJw8rz6{NAxiR~fIV7y5(BoxIumgxI41$}>}sLuzjjINj& z#m$T&a$ACBC0%vF_h9B5yDGT!qD;YCYVC&g2mhb-ScXJUahbUX=2f%b6g=pW8}Caa z&R(ooXbSWWAtm1|uPDu@0uFzb{a+3v7D>d8IW;SnLR@cqW=UYmy6k>5OIoI|us(4c zRdx?ob@Qs>Rox&#UNGTb=;H8QH!={6j26AcwTa(a*0~8S!MT=zlG51s}_oiLf4!B-dnam`_eve*w&3XulCFW zMmb;Fp7US$!kw?Zpf^{CnlCIQ?&o{7!G#g#4qn4)=nih%wae+K2n{>B@jS8foBGM> zhgN*DfEgvfj5%o~-tAS|fPS&M-M;MR=wkH_r@d)mbZy<`X6M#L+qJ)qR&^`nzaThz AAOHXW diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index 710270e..17ccb05 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -1622,25 +1622,37 @@ impl Analyzer { return; } // The v0.1 calling convention passes parameters via four - // dedicated zero-page slots ($04-$07). Anything past the - // fourth parameter is silently dropped by the codegen - // (see `codegen/ir_codegen.rs` around the param-slot - // mapping loop) — which produces a runtime miscompile - // with no compile-time warning. Catch the over-arity case - // here so users get a clear error instead. - if fun.params.len() > 4 { + // Parameters are passed through zero-page transport slots + // for *leaf* functions only; non-leaf functions use a + // direct-write calling convention where the caller stages + // each argument straight into the callee's analyzer- + // allocated parameter RAM slot, bypassing the transport + // slots entirely. That lifts the per-function parameter cap + // from 4 (the number of ZP transport slots at $04-$07) to 8 + // for non-leaves. Leaves still cap at 4 because their bodies + // read `$04-$07` directly and there's nowhere to put extras. + // + // The analyzer can't know which functions will be leaves + // without running the leaf-analysis that only exists in the + // codegen, so we apply the looser 8-param cap here and let + // the codegen's `function_is_leaf` check demote any + // 5-to-8-param function to non-leaf automatically. The net + // user-visible rule: 1–4 params works everywhere; 5–8 params + // works but forbids the leaf fast path. + if fun.params.len() > 8 { self.diagnostics.push( Diagnostic::error( ErrorCode::E0506, format!( - "function '{}' has {} parameters; the maximum is 4 in this version", + "function '{}' has {} parameters; the maximum is 8", fun.name, fun.params.len() ), fun.span, ) .with_help( - "the v0.1 ABI passes parameters via four fixed zero-page slots ($04-$07); pass extras through globals or split the function".to_string(), + "pass related data through a struct global, or split the function into two" + .to_string(), ), ); return; diff --git a/src/analyzer/tests.rs b/src/analyzer/tests.rs index 131c5a2..98f5a6a 100644 --- a/src/analyzer/tests.rs +++ b/src/analyzer/tests.rs @@ -2143,24 +2143,28 @@ fn analyze_debug_frame_overrun_count_with_args_errors() { } #[test] -fn analyze_rejects_function_with_more_than_4_params() { - // The v0.1 calling convention only allocates 4 zero-page - // parameter slots ($04-$07). A function with 5 params would - // silently corrupt the 5th param at runtime, so we reject it - // at compile time with E0506. +fn analyze_rejects_function_with_more_than_8_params() { + // The calling convention allocates four zero-page transport + // slots ($04-$07) that leaves pass through, and a per-function + // RAM spill region that non-leaves direct-write into. Non-leaf + // functions scale past four params cleanly via the spill + // region, and we cap at eight both to keep the calling + // convention simple and because any call site needing more + // than eight arguments is almost certainly better served by a + // struct global. let errors = analyze_errors( r#" game "T" { mapper: NROM } - fun too_many(a: u8, b: u8, c: u8, d: u8, e: u8) { + fun too_many(a: u8, b: u8, c: u8, d: u8, e: u8, f: u8, g: u8, h: u8, i: u8) { a = 0 } - on frame { too_many(1, 2, 3, 4, 5) } + on frame { too_many(1, 2, 3, 4, 5, 6, 7, 8, 9) } start Main "#, ); assert!( errors.contains(&ErrorCode::E0506), - "expected E0506 for function with >4 params, got: {errors:?}" + "expected E0506 for function with >8 params, got: {errors:?}" ); } diff --git a/src/codegen/ir_codegen.rs b/src/codegen/ir_codegen.rs index 3ad399b..1b4f481 100644 --- a/src/codegen/ir_codegen.rs +++ b/src/codegen/ir_codegen.rs @@ -22,7 +22,7 @@ //! (`0x80-0xFF`) for IR temp storage per function. Functions reset the //! temp counter at entry. -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use crate::analyzer::VarAllocation; use crate::asm::{AddressingMode as AM, Instruction, Opcode::*}; @@ -261,13 +261,6 @@ pub struct IrCodeGen<'a> { /// dispatcher loop, top-level functions). current_bank: Option, allocations: &'a [VarAllocation], - /// Set of function names whose body never `JSR`s any other - /// routine. Leaf functions skip the parameter-spill prologue - /// (`LDA $04 / STA `) entirely — they can read their - /// arguments straight out of the transport slots `$04..$07` - /// for the lifetime of the body, since nothing else clobbers - /// those slots from inside. Detected by [`function_is_leaf`]. - leaf_functions: HashSet, /// Per-function-scoped overrides for inline-asm `{name}` /// substitution. Leaf functions point their parameters at the /// transport slots `$04..$07` instead of the analyzer's @@ -275,6 +268,20 @@ pub struct IrCodeGen<'a> { /// body resolves to the live transport slot rather than the /// stale per-function spill destination. leaf_param_overrides: HashMap, + /// For every non-leaf function, the ordered list of addresses + /// its parameters occupy. The direct-write calling convention + /// has the *caller* of a non-leaf function stage each argument + /// straight into these slots before the `JSR`, bypassing the + /// `$04-$07` transport entirely. This saves the 12-to-16-cycle + /// spill prologue and, more importantly, lifts the 4-param cap + /// that the transport slots imposed. Populated alongside + /// `leaf_functions` in [`IrCodeGen::new`]. + /// + /// Leaves are deliberately absent: their callers still use the + /// `$04-$07` transport (which the body reads directly, since + /// leaves have no nested `JSR` to clobber them), and a lookup + /// miss here means "this callee is a leaf, use the transport". + non_leaf_param_addrs: HashMap>, } impl<'a> IrCodeGen<'a> { @@ -343,25 +350,40 @@ impl<'a> IrCodeGen<'a> { // `{name}` substitution agrees). `gen_function` skips the // spill prologue entirely for these — saving 12 cycles // and 12 bytes of code per leaf-function call site. - let mut leaf_functions: HashSet = HashSet::new(); let mut leaf_param_overrides: HashMap = HashMap::new(); + let mut non_leaf_param_addrs: HashMap> = HashMap::new(); for func in &ir.functions { - if !function_is_leaf(func) { - continue; - } - leaf_functions.insert(func.name.clone()); - let scope = scope_prefix_for_fn(&func.name); - for (i, local) in func - .locals - .iter() - .take(func.param_count) - .take(4) - .enumerate() - { - let addr = 0x04u16 + i as u16; - var_addrs.insert(local.var_id, addr); - let qualified = format!("__local__{scope}__{}", local.name); - leaf_param_overrides.insert(qualified, addr); + if function_is_leaf(func) { + let scope = scope_prefix_for_fn(&func.name); + for (i, local) in func.locals.iter().take(func.param_count).enumerate() { + let addr = 0x04u16 + i as u16; + var_addrs.insert(local.var_id, addr); + let qualified = format!("__local__{scope}__{}", local.name); + leaf_param_overrides.insert(qualified, addr); + } + } else if func.param_count > 0 { + // Non-leaf: callers direct-write each argument into + // these param slots. Resolve each param's allocated + // address from `var_addrs` (which was populated from + // the analyzer's per-function spill allocations a + // few loops up). A miss here means the analyzer + // didn't allocate a slot — which would be a + // compiler bug of the PR-#31 shape. + let addrs: Vec = func + .locals + .iter() + .take(func.param_count) + .map(|local| { + *var_addrs.get(&local.var_id).unwrap_or_else(|| { + panic!( + "internal compiler error: parameter {:?} of \ + function {:?} has no allocated address", + local.name, func.name + ) + }) + }) + .collect(); + non_leaf_param_addrs.insert(func.name.clone(), addrs); } } let function_names = ir.functions.iter().map(|f| f.name.clone()).collect(); @@ -413,8 +435,8 @@ impl<'a> IrCodeGen<'a> { function_banks, current_bank: None, allocations, - leaf_functions, leaf_param_overrides, + non_leaf_param_addrs, } } @@ -982,44 +1004,25 @@ impl<'a> IrCodeGen<'a> { self.emit_label(&format!("__ir_fn_{}", func.name)); - // Prologue: spill the parameter-transport slots $04-$07 - // into the function's per-local RAM slots. The caller - // stages arguments into $04-$07 before the JSR; this - // copy makes sure that when the function body later - // makes a nested call (which clobbers $04-$07 again to - // pass its own arguments), the caller's parameters are - // still available in their dedicated RAM slots. + // No parameter-spill prologue. Two call-conventions handle + // arguments entirely at the *call site*: // - // Parameters are the first `param_count` entries of - // `func.locals`. The analyzer refuses functions with - // more than 4 parameters (E0506), so the `.take(4)` - // below is a defensive guard — it should never be hit - // in a well-formed program. + // - Leaf functions: caller writes to the fixed transport + // slots `$04..$07`, body reads them directly for its + // entire lifetime. No prologue copy needed because + // leaves never `JSR` (by definition), so the transport + // slots never get clobbered from inside the body. // - // Leaf functions skip this entirely: their var_addrs - // were already rewired to $04-$07 in `IrCodeGen::new`, - // and since they never JSR from inside their body, the - // transport slots stay live for the whole function. This - // saves 12 cycles per call to every leaf primitive — the - // SHA-256 example's `cp_wk`, `xor_wk`, `add_wk`, etc. are - // all leaves. - if !self.leaf_functions.contains(&func.name) { - for (i, local) in func - .locals - .iter() - .take(func.param_count) - .take(4) - .enumerate() - { - let addr = self.var_addr(local.var_id); - self.emit(LDA, AM::ZeroPage(0x04 + i as u8)); - if addr < 0x100 { - self.emit(STA, AM::ZeroPage(addr as u8)); - } else { - self.emit(STA, AM::Absolute(addr)); - } - } - } + // - Non-leaf functions: caller direct-writes each arg + // into the callee's per-parameter RAM slot (see + // `non_leaf_param_addrs`). No prologue copy needed + // because the arg is already exactly where the body + // expects to read it. + // + // The old "transport-then-spill" prologue added 4 LDA/STA + // pairs (~28 cycles, 16 bytes) at every non-leaf entry. + // Skipping it saves the cycles *and* removes the hard + // 4-param ceiling that the four transport slots imposed. // At the start of a frame handler that actually draws // sprites, clear the OAM shadow buffer so stale sprites from @@ -1396,20 +1399,55 @@ impl<'a> IrCodeGen<'a> { } } IrOp::Call(dest, name, args) => { - // Pass up to 4 arguments via zero-page slots $04-$07. - // E0506 rejects function declarations with more than - // four parameters, so a call with >4 args is a - // compiler-internal inconsistency — panic rather than - // silently drop the extras (PR-#31-shaped miscompile). + // Two calling conventions, selected by callee shape: + // + // - **Leaf callees** (no nested `JSR` in body AND + // ≤4 params): stage each argument into the fixed + // zero-page transport slots `$04..$07`. The + // callee reads them straight out of `$04..$07` + // for its entire body — leaves never clobber + // those slots, so no prologue copy is needed. + // Fastest path; 3-cycle ZP stores and 3-cycle + // ZP loads inside the body. + // + // - **Non-leaf callees** (≥1 nested `JSR` OR 5–8 + // params): direct-write each argument straight + // into the callee's analyzer-allocated parameter + // RAM slot. No transport step, no prologue copy. + // Saves ~24 cycles and ~16 bytes per call vs the + // old "transport + spill" ABI, and — crucially — + // scales past 4 params because the slots live + // wherever the analyzer put them, not in a fixed + // ZP window. E0506 caps declarations at 8 so the + // assert below stays a belt-and-braces guard. assert!( - args.len() <= 4, + args.len() <= 8, "internal compiler error: Call to {name:?} with \ - {} args (max 4); E0506 should have caught this", + {} args (max 8); E0506 should have caught this", args.len() ); - for (i, arg) in args.iter().enumerate() { - self.load_temp(*arg); - self.emit(STA, AM::ZeroPage(0x04 + i as u8)); + if let Some(param_addrs) = self.non_leaf_param_addrs.get(name).cloned() { + debug_assert_eq!( + args.len(), + param_addrs.len(), + "arity mismatch at non-leaf call to {name}: \ + {} args vs {} params", + args.len(), + param_addrs.len() + ); + for (arg, addr) in args.iter().zip(param_addrs.iter()) { + self.load_temp(*arg); + if *addr < 0x100 { + self.emit(STA, AM::ZeroPage(*addr as u8)); + } else { + self.emit(STA, AM::Absolute(*addr)); + } + } + } else { + for (i, arg) in args.iter().enumerate() { + self.load_temp(*arg); + self.emit(STA, AM::ZeroPage(0x04 + i as u8)); + } } // Pick the right JSR target. Four cases: // 1. Caller and callee are both in the fixed bank @@ -2587,9 +2625,18 @@ fn scope_prefix_for_fn(name: &str) -> String { } } -/// True if `func` doesn't `JSR` any other routine from inside its -/// body. Leaf functions skip the parameter-spill prologue and read -/// their args straight out of the transport slots `$04..$07`. +/// True if `func` is eligible for the "leaf" calling convention: +/// its body emits no `JSR`, and its parameter count fits in the +/// four zero-page transport slots (`$04-$07`). Leaf-eligible +/// functions read their args straight out of the transport slots +/// and skip the spill prologue entirely. +/// +/// The `param_count <= 4` check matters for the direct-write +/// calling convention: non-leaf (5-to-8-param) functions have the +/// caller stage each argument directly into the callee's +/// per-parameter RAM slot, which is where leaves *can't* live +/// because their params would then need a prologue copy out of +/// `$04-$07` — defeating the whole point of the leaf fast path. /// /// The match below is exhaustive on `IrOp` so adding a new variant /// that secretly emits a `JSR` (e.g. a future `Mul16` calling a @@ -2598,6 +2645,9 @@ fn scope_prefix_for_fn(name: &str) -> String { /// ops are: `Call`, `Mul`, `Div`, `Mod`, `Transition`, plus /// `InlineAsm` bodies that mention `JSR` as a token. fn function_is_leaf(func: &IrFunction) -> bool { + if func.param_count > 4 { + return false; + } for block in &func.blocks { for op in &block.ops { // Returning `false` from any arm marks the function as @@ -4473,30 +4523,27 @@ mod more_tests { } #[test] -fn gen_function_prologue_spills_params_to_local_ram() { - // Regression test for the param-slot clobbering bug fixed - // on the War bug-cleanup branch (see `git log`): without the - // param-spill prologue, a function's parameters live only - // in the transport slots $04-$07. The first nested call - // inside the body would overwrite those slots with its - // own arguments, silently corrupting the caller's params. +fn non_leaf_call_direct_writes_args_to_callee_param_slots() { + // Non-leaf functions receive their parameters via direct + // caller writes to the callee's analyzer-allocated RAM slot, + // bypassing the `$04-$07` transport entirely. That's both + // faster (~24 cycles saved per call by eliminating the old + // spill prologue) and what lifts the 4-param ceiling — the + // slots live wherever the analyzer put them. // // Compile a function that takes `x: u8`, calls `helper(x)`, - // then uses `x` again. Verify that immediately after the - // `__ir_fn_caller` label, the codegen emits a spill - // `LDA $04 / STA ` where `` is the analyzer's - // dedicated address for the param — crucially, not $04 - // itself (which nested calls would clobber) and not - // $05/$06/$07 either. + // and verify two invariants: // - // Earlier revisions of this test asserted `` had to - // be an absolute address at `$0300+`, reflecting a codegen - // that minted a fresh per-function RAM range. After - // `compiler-bugs.md` #1 — the inline-asm `{param}` - // resolution fix — the codegen reuses the analyzer's - // allocation, which can land in zero page when there's - // room. The invariant that matters is "separate from the - // transport slots", not "must be main RAM". + // 1. The caller of `caller` stages its argument at the + // RAM slot the analyzer allocated for `x` (NOT at + // `$04-$07`), because `caller` is non-leaf. + // 2. The callee `caller`'s entry does NOT emit an + // `LDA $04 / STA ` prologue — args are already + // where the body expects to read them. + // + // Together these assertions would have failed the old ABI + // as well, but in the opposite direction — they're a + // regression guard for the new convention. use crate::parser; let src = r#" game "Test" { mapper: NROM } @@ -4521,45 +4568,67 @@ fn gen_function_prologue_spills_params_to_local_ram() { let mut codegen = IrCodeGen::new(&analysis.var_allocations, &ir); let insts = codegen.generate(&ir); - // Walk the instructions emitted for `caller` (up until the - // next function label) looking for the spill `LDA $04` / - // `STA ` pair. `` is accepted as either - // `ZeroPage(addr)` or `Absolute(addr)`, as long as `addr` - // is outside the `$04-$07` transport range. + // (2) The body of `caller` must not open with a prologue + // `LDA $04` — the first thing inside `__ir_fn_caller` + // should be related to the nested JSR to `helper`, not + // a spill copy. let caller_idx = insts .iter() .position(|i| i.mode == AM::Label("__ir_fn_caller".into())) .expect("caller function should be emitted"); - let mut saw_lda_zp4 = false; - let mut saw_sta_separate = false; - for inst in &insts[caller_idx + 1..] { - if let AM::Label(l) = &inst.mode { - if l.starts_with("__ir_fn_") && l != "__ir_fn_caller" { - break; - } - } - if inst.opcode == LDA && inst.mode == AM::ZeroPage(0x04) { - saw_lda_zp4 = true; - continue; - } - if saw_lda_zp4 && inst.opcode == STA { - let addr: u16 = match inst.mode { - AM::ZeroPage(a) => u16::from(a), - AM::Absolute(a) => a, - _ => continue, - }; - if !(0x04..=0x07).contains(&addr) { - saw_sta_separate = true; - break; - } - } + for inst in insts[caller_idx + 1..].iter().take(4) { + assert!( + !(inst.opcode == LDA && inst.mode == AM::ZeroPage(0x04)), + "non-leaf `caller` should not open with an `LDA $04` \ + prologue under the direct-write ABI — args are written \ + to caller's slots by the caller, not copied here" + ); } + + // (1) Walk the frame handler (which invokes `caller`) and + // locate the `STA` that stages the argument. Because + // `caller` is non-leaf, the staging STA must go to + // `caller.x`'s allocated slot — which the analyzer + // records under `"__local__caller__x"`. The target + // must be outside `$04-$07`. + let x_slot = analysis + .var_allocations + .iter() + .find(|a| a.name == "__local__caller__x") + .expect("analyzer should allocate a slot for caller's param `x`") + .address; assert!( - saw_lda_zp4 && saw_sta_separate, - "caller function should open with `LDA $04` followed by \ - a `STA ` that spills the param out of the \ - transport slots — the param-clobbering fix is not in \ - effect" + !(0x04..=0x07).contains(&x_slot), + "non-leaf param slot must live outside the `$04-$07` \ + transport range, got ${x_slot:04X}" + ); + + // The staging STA may land in either Main_frame or in + // caller's body (if the test source becomes nested). Scan + // from the start of Main_frame and require the slot to be + // hit at least once before any __ir_fn_ label other than + // Main_frame / caller. + let frame_idx = insts + .iter() + .position(|i| i.mode == AM::Label("__ir_fn_Main_frame".into())) + .expect("Main_frame handler should be emitted"); + let saw_stage = insts[frame_idx + 1..].iter().any(|inst| { + if inst.opcode != STA { + return false; + } + let addr: u16 = match inst.mode { + AM::ZeroPage(a) => u16::from(a), + AM::Absolute(a) => a, + _ => return false, + }; + addr == x_slot + }); + assert!( + saw_stage, + "caller site should stage the `42` argument directly at \ + `caller.x`'s slot (${x_slot:04X}), not at `$04`. \ + Emitted instructions follow: {:#?}", + &insts[frame_idx + 1..].iter().take(40).collect::>() ); } diff --git a/src/errors/diagnostic.rs b/src/errors/diagnostic.rs index 8ab6a98..fd7833d 100644 --- a/src/errors/diagnostic.rs +++ b/src/errors/diagnostic.rs @@ -33,7 +33,7 @@ pub enum ErrorCode { E0503, // undefined function E0504, // missing start declaration E0505, // multiple start declarations - E0506, // function has too many parameters (max 4 in v0.1) + E0506, // function has too many parameters (max 8) // E06xx: Unsupported-feature errors (parsed but not lowered) E0601, // state-local variable with array initializer is not supported diff --git a/tests/emulator/goldens/pong.audio.hash b/tests/emulator/goldens/pong.audio.hash index 469676e..c252042 100644 --- a/tests/emulator/goldens/pong.audio.hash +++ b/tests/emulator/goldens/pong.audio.hash @@ -1 +1 @@ -6c171f3d 132084 +6afbe82b 132084 diff --git a/tests/emulator/goldens/war.audio.hash b/tests/emulator/goldens/war.audio.hash index c832f7e..55b2161 100644 --- a/tests/emulator/goldens/war.audio.hash +++ b/tests/emulator/goldens/war.audio.hash @@ -1 +1 @@ -60d2ce9d 132084 +b054facb 132084 diff --git a/tests/integration_test.rs b/tests/integration_test.rs index a4afc78..792ff20 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -335,6 +335,97 @@ fn program_with_u16_struct_field() { rom::validate_ines(&rom_data).expect("should be valid iNES"); } +#[test] +fn eight_param_non_leaf_function_stages_every_arg_at_its_allocated_slot() { + // Non-leaf functions use direct-write calling convention: the + // caller stages each argument at the callee's analyzer- + // allocated parameter slot, bypassing the four-slot `$04-$07` + // transport. That lifts the 4-param ceiling to 8 (E0506) and + // saves the old prologue's ~28 cycles per call. + // + // Verify end-to-end by compiling a function that takes eight + // distinct u8 params (so any cross-wiring would show up) and + // writes each to a distinct global. Then scan the PRG for an + // `LDA #N / STA ` pair per arg — eight different + // immediates, eight different destination slots. The eight + // immediates `0x11..0x88` are chosen to be visually + // distinctive and unlikely to appear as incidental runtime + // constants. + let source = r#" + game "EightParams" { mapper: NROM } + var g0: u8 = 0 + var g1: u8 = 0 + var g2: u8 = 0 + var g3: u8 = 0 + var g4: u8 = 0 + var g5: u8 = 0 + var g6: u8 = 0 + var g7: u8 = 0 + fun spread(a: u8, b: u8, c: u8, d: u8, e: u8, f: u8, g: u8, h: u8) { + g0 = a + g1 = b + g2 = c + g3 = d + g4 = e + g5 = f + g6 = g + g7 = h + } + on frame { + spread(0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88) + } + start Main + "#; + let rom_data = compile(source); + let prg = &rom_data[16..16 + 16384]; + + // For each of the eight immediates, require at least one + // `LDA #imm / STA ` pair anywhere in PRG. The STA + // target can be zero-page ($85) or absolute ($8D); we don't + // pin down which because the analyzer picks the cheapest + // slot available. + for imm in [0x11u8, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88] { + let found = prg + .windows(4) + .any(|w| w[0] == 0xA9 && w[1] == imm && (w[2] == 0x85 || w[2] == 0x8D)); + assert!( + found, + "expected an LDA #${imm:02X} / STA pair for argument \ + staging — if this fails, the 8-param non-leaf call is \ + dropping args again" + ); + } + + // Belt-and-braces: in the OLD ABI every arg first got + // staged to $04-$07 (four ZP addresses). The new ABI stages + // *nothing* to those addresses for this call (spread has + // more than four params, so it's forced non-leaf, so direct- + // write). If someone re-introduces the old transport path, + // we'd see STA $04/$05/$06/$07 pairs. Assert absence. + for transport_slot in 0x04..=0x07u8 { + let any_store = prg + .windows(2) + .any(|w| w[0] == 0x85 && w[1] == transport_slot); + // The runtime itself may write to $04-$07 (they're not + // reserved outside of this calling convention), so we + // can't assert zero globally. Just require that the + // number of STA-to-transport stores is strictly smaller + // than the 8 args — if the old transport path were + // active we'd see eight extra such stores. + let _ = any_store; // intentional: see count check below + } + let transport_sta_count = prg + .windows(2) + .filter(|w| w[0] == 0x85 && (0x04..=0x07).contains(&w[1])) + .count(); + assert!( + transport_sta_count < 8, + "the 8-param call should NOT stage any arg through the \ + `$04-$07` transport slots under the direct-write ABI; saw \ + {transport_sta_count} `STA $04-$07` instructions total in PRG" + ); +} + #[test] fn transition_dispatches_leaving_states_on_exit_handler() { // `on exit` handlers used to be silently never called — the