From 8ababdcec405e82495a186b42ed061c74dc642ac Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 15:22:20 +0000 Subject: [PATCH] examples/war: working end-to-end War card game MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A complete, playable port of the card game War: title screen with 0/1/2 player menu, animated deal, sliding cards, deck-count HUD, a "WAR!" tie-break with buried cards, and a victory screen with a fanfare. Source split across examples/war/*.ne (constants, assets, audio, deck/queue logic, RNG, render helpers, and one state file per game state) and pulled in via examples/war.ne. Drives nearly every NEScript subsystem at once: custom 88-tile sprite sheet (card frames, ranks, suits, font, BIG WAR letters); felt background nametable; pulse-1 / pulse-2 / noise sfx; looping march on pulse 2; an 8-bit Galois LFSR PRNG; queue-based decks that conserve cards across rounds; a phase machine inside the Playing state that handles draw/reveal/win/war/check; and an autopilot that boots straight into 0-PLAYERS mode so the headless jsnes harness captures real gameplay at frame 180. While building this I uncovered five compiler bugs / limitations in the v0.1 implementation; each is documented with a minimal reproduction, root cause, current workaround, and proposed fix in examples/war/COMPILER_BUGS.md. The most painful was the parameter-VarId aliasing one (#1b) — two functions sharing a parameter NAME end up sharing a single zero-page slot mapping across the whole program. Once those compiler bugs are fixed, the workarounds in war/*.ne should be reverted in the same PR. https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z --- examples/war.ne | 100 +++ examples/war.nes | Bin 0 -> 24592 bytes examples/war/COMPILER_BUGS.md | 435 +++++++++++++ examples/war/assets.ne | 856 ++++++++++++++++++++++++++ examples/war/audio.ne | 84 +++ examples/war/background.ne | 57 ++ examples/war/compare.ne | 30 + examples/war/constants.ne | 170 +++++ examples/war/deal_state.ne | 88 +++ examples/war/deck.ne | 188 ++++++ examples/war/play_state.ne | 311 ++++++++++ examples/war/render.ne | 226 +++++++ examples/war/rng.ne | 41 ++ examples/war/state.ne | 82 +++ examples/war/title_state.ne | 144 +++++ examples/war/victory_state.ne | 73 +++ tests/emulator/goldens/war.audio.hash | 1 + tests/emulator/goldens/war.png | Bin 0 -> 10005 bytes 18 files changed, 2886 insertions(+) create mode 100644 examples/war.ne create mode 100644 examples/war.nes create mode 100644 examples/war/COMPILER_BUGS.md create mode 100644 examples/war/assets.ne create mode 100644 examples/war/audio.ne create mode 100644 examples/war/background.ne create mode 100644 examples/war/compare.ne create mode 100644 examples/war/constants.ne create mode 100644 examples/war/deal_state.ne create mode 100644 examples/war/deck.ne create mode 100644 examples/war/play_state.ne create mode 100644 examples/war/render.ne create mode 100644 examples/war/rng.ne create mode 100644 examples/war/state.ne create mode 100644 examples/war/title_state.ne create mode 100644 examples/war/victory_state.ne create mode 100644 tests/emulator/goldens/war.audio.hash create mode 100644 tests/emulator/goldens/war.png diff --git a/examples/war.ne b/examples/war.ne new file mode 100644 index 0000000..85c3a76 --- /dev/null +++ b/examples/war.ne @@ -0,0 +1,100 @@ +// War — a full NES port of the card game. +// +// This is a production-quality example: a title screen with a menu, +// an animated deal, cards that slide between two decks and a central +// play area with a running card count, a dedicated "WAR!" tie-break +// with buried cards, and a victory screen with a fanfare. Every +// piece of the game (logic, art, sound, states) is authored in +// NEScript itself — no external assets, no external tooling. +// +// The source is split across examples/war/*.ne files. This top-level +// file declares the hardware config, the reset-time palette and +// background, the one big Tileset sprite block that carries every +// custom CHR tile, the audio includes, the state includes, and the +// `start` declaration. Every include is processed by the parser's +// preprocess pass before lexing, so it's exactly as if the whole +// game lived in one .ne file. +// +// The file layout mirrors how a real NES game would be organised: +// +// examples/war.ne — this file, the top-level game +// examples/war/PLAN.md — living design doc +// examples/war/constants.ne — layout + gameplay constants +// examples/war/audio.ne — sfx + music declarations +// examples/war/state.ne — global variables +// examples/war/rng.ne — 8-bit LFSR PRNG +// examples/war/deck.ne — queue operations on the decks +// examples/war/compare.ne — card rank/suit extraction +// examples/war/render.ne — card + digit drawing helpers +// examples/war/title_state.ne — Title state + menu +// examples/war/deal_state.ne — Deal state + dealing animation +// examples/war/play_state.ne — Playing state + phase machine +// examples/war/victory_state.ne — Victory state +// +// Controls (human players): +// D-pad up/down — move the title menu cursor +// A / Start — confirm a menu choice / draw the next card +// / return to the title from the victory screen +// +// In 0-player mode both players are CPU and the game plays itself; +// in 1-player mode you are player A and the CPU is player B; in +// 2-player mode both sides are human. The headless jsnes golden +// harness boots straight into 0-player mode (title auto-confirms +// after ~45 frames with no input) so the captured frame is always a +// scene from actual gameplay. +// +// Build: cargo run --release -- build examples/war.ne +// Output: examples/war.nes + +game "War" { + mapper: NROM + mirroring: horizontal +} + +// ── Palette ───────────────────────────────────────────────── +// +// Grouped form with a shared `universal:` so the $3F10 mirror +// trap is handled automatically. The sprite sub-palette 0 is the +// one the NEScript codegen hardwires into the OAM attribute byte, +// so every sprite in the game renders through it. Four colours: +// +// 0 = transparent (universal — dk_green felt) +// 1 = red (hearts, diamonds, accents) +// 2 = white (card face, text) +// 3 = black (outlines, spades, clubs) +// +// The three bg sub-palettes carry the felt, the cream banner, and +// a warm accent for the "WAR!" flash; bg3 stays reserved for +// future tweaks. +palette Main { + universal: dk_green // green felt table colour + + bg0: [forest, green, mint] // felt table base + subtle pattern + bg1: [dk_red, red, white] // cream / red banners + bg2: [black, lt_gray, white] // card body on the nametable + bg3: [dk_olive, olive, yellow] // warm accent, "WAR!" flash + + sp0: [red, white, black] // every sprite uses this + sp1: [dk_red, red, peach] // reserved + sp2: [dk_blue, blue, sky_blue] // reserved + sp3: [dk_gray, lt_gray, white] // reserved +} + +// Pull in everything else. Order matters only for symbol visibility: +// constants before the state variables that use them, state variables +// before helper functions, helper functions before state handlers. +include "war/constants.ne" +include "war/assets.ne" +include "war/background.ne" +include "war/audio.ne" +include "war/state.ne" +include "war/rng.ne" +include "war/deck.ne" +include "war/compare.ne" +include "war/render.ne" +include "war/title_state.ne" +include "war/deal_state.ne" +include "war/play_state.ne" +include "war/victory_state.ne" + +start Title diff --git a/examples/war.nes b/examples/war.nes new file mode 100644 index 0000000000000000000000000000000000000000..8fe9e6f0f61cd2002832f3cec6275ba2e21597b0 GIT binary patch literal 24592 zcmeHNYj70TmF}54n&}yh9(V;F2>}gASOS3uLu|#KNE4^n1u}Ms?Ktu7mTPL)$f6>< z*jK`?DM?sdlXayXsy4}ntYxrp8?JEMV7xH~3lKs=12!6-;;{r%3R<(SNCT7^Y=mad zx&4@K9>Lk#KdI`5n!e|K?$_tuKHaTH=RIEHatY?K>_l7imAj*DnO`$O79`mv$z!98 z;7?c{>Mq33pgAr*JJgq`btKe-Fe4i%IX?o4z zPIXJHR`yEZ8-Bh{-Rv|a>x3x zCx6F^LF=r=O*DChO@zdmKtr^cmeBFElun=%=_KZ&5}geEDRe3=qvdoOy@yr+w~|f= z?hIN*XVO{pUK*ye*#>$ar1>h5TL~r7iUW#}8UZCiAqgQ#V#5a#E;t?TOSs`Q_E3Vr zsiZ$v(y81hzeXxu@>-(4Mi#s<6UKCYCuE1uSRBuXJIia+w3M17NVm#)USyns=PfNt-o zC*a|KU=BlXlGt;54DAg>-C$@lO?!i(T@Ccm1`_)ZUE3vZBoKfRbt6gSrF=ZZZ_y|U z>~^BIlZ1yeFC%j*s0HAVH<7${LR(Zu+LabSVu(S;L+vDX9s_J7v4eXs@wbTj7D@DS z2Xni?!PC;WIxA~sm1yU;iK>#sTrPhC<@c*^8}u_sPXjr>F7FVK;D|OOOKFCn$WhHUYl8?6- z+!-3z4XI%vZ3S!jJ)(H!|HP{YUVcMVKKy-`{61m1q33=>sxP?YZG?qF&%ICN4+tye zgYAS#e6WM4TS;h%D1S)Q_xPYg8x+briMoxv;Rp5_!41PTF4$+*xZPl|hPVg)+CTM! ztj-5noeym~+fk>3``Mn=vHRJP)!C8NvH4*+;ASVfVL7+XhIvg5uc=}40l?vOcL8>I z-p$X2awlQFH~dNt`t})?ipMfhCK__lkgL0_vt)jVy_;v}H#F=X0;WR1P`JSbEA~c(+Ez zS=mN$8!^B4Cf$Z`!LWrO<^Ao#5v+koV=Sj zcbQIlElzs56VB0dvv9I;6P5`kxC!TI3aW*pCvV|sajiN=Ce!rm{#U(lRRT&`Xo-iG z#h&j~%BnBGNV!Lszo5y>QC=Qzb3jH^}C9Hkh*QhOLmkQ~p+xYXAv?*}BLC-=mq347!N`dC|| z9}#WMe$3BB@_ ztA6$!Q{g*?!VNAK88H@_3I}a5zH2Ca*Hq}UDLiJzc+AwWyLjBxc-+*m`*^|#@`S07 zv<3N|q3}IZ;gC(?Ni)WiriR_c_f3uOn;JGB)_VDLKh(=N;YveSSb^Q2Q94jqUh0#6 z*2^knH#WK?%GT2$J4N$gtGVKn(>|7=0Wk4M>w2{wNh`C54uH#VLBT+qskziE4s^ZX z_A5BWLIbB@p!K3NX%*W|r#X-U>^&gMjYA&}O*puuxymGc`E1AVD2DbHnAl4S zu|8yqpeca>*>27r)O3MOC&$|a-nD-UM=}kne)UyY`YyUsZ6!+tN|lw+0V@=G;K3P# zGnJW%2;XF!tl27uqSySJHE*|vlGo&B#oO(n;5Gkd z!Ly%Q{X@NLa>9y4^mmZFbDfZwIViC$-I=E!9bFV`s~B z67Pqp9`+NR^lId4yX~)ZGN?({>7;f?U_St|9%>Z>t?I$Jup5ut$N9^E4@*JkLsV1p z6Odr0ngt|)5@k9z<%Q(sr6n}sa)@j{-?0L zgdn+Fs)45TU-%3F&$98`ujqS~dlPQ%?n`tx2Mq32X6X+uc}ZYNgR|~1`C0N2eAGHh z)FbeCqVEI?&^8}9)fY-Y#7aO3<;G621m)Hnwt6%xd`T1P`zJJr#vL0xK$&E3*{TY~*}4yNg=Y?15*`OUNEok&p0KDVeE(rZ5}FGpL{t?IoySKgrksq6yU)|5UltKC>Z8Qm%8NR zB%xh}xu6R*x5(@lHtPMtR_GVlDWhZX@(3!JX;wOslUpG<(*3 z)3XK1G14#L?F0=od`11oUn}>?`x5azT_8;9J!zGK4uLUxdz5zT^YH zS{eWJM!&7M@Vn)Y@$2I=_}z_uKfi@v|8YKtua<9s`;DN*M#;Zpl$6~=GuNgo3%r)J(Ks^r*GA3 z|1qudY;Y(>Ax!S)C`Fz8&suqkW!?`Zgt zrNlNOqf~#j+fTRy*xb~9ydTcl8#|==1Qxzt37)W2;(%tVy~jO~H9eu522U8?@W#6U z8qJN^h%YdEu0@uf>sIpk9pp>zAh$K*w}`*`4)*ITa`Vo5_Jn?CU8$^uTRnU?fcN>h zQO-`h$BXRC6M(2i05Z6Mpw}k;xqG!Qq>#lPO1z%^$&^B{wd5kNXBtsR^?CPd5DtQH zNP*Yzfz(6rxgwYh@((7U8HhEXgij}f!K8aIPfhz$V}c_(IQ1)J07~?OOm0q zPw`c{Q$Oy>P5uKo$xn*>X)pHC$wVeKFShn%JIHs#ZOB&{S-3FAKmXI;3p+bL)c$W57A;y7jYh-aaD|l% zG;l!{7c$qceR1{6kx@LSKfioYXpTq2k`#`{VIaXF)IRjU9HGin<(Vy3iIrlxh~p}8 zw)i!mXT$hwFh+@?Q(h>DnM`@0GEfzmT_glD8GoSoLBDrg*kACCuwM*S_{W6{fWF`F z8Z+51d?WmA=Wq~k5crct!2Ck~e;mwzdH%2FEAIIUnJ<0wB@SA#Fa5{M|i;YVVr*uFX9ULLA(e*h!-himk4w$d=LlYaa@KU!UZ~px#V%U zfsc!}iR!rM!f7t`Zuk}VCoe+BB-B$)Y$2(TsCA2sj8h2sj8h2sj8h2sj8h z2sj8h2sj8h2sj8h2sj8h2sj8h2sj8h2sj8h2sj8h2sj8h2sj8h2sjA*DI@UISz-6+ z*~r7n3T4g1kw_!Xr7=<)jW**^+tif8W5n%t;W08gT7U-^Mx(V{*wj?Zg>Lr<7mkjO za5?-K$Z$FQ7<6sZvjd%7`_kb5vorna^vLMIzLtFhaK6^RqklM^-q+PR@N83UI^BQf zGw`40;?a@xFo>^>jtHShS){B?$eiv_Ius=%L_8r$iU^qwdV2l(4o-VKNOzy^ra+gK zl|?)rA)_ctM|U^KB_L709!S)ez@0{e2HJ~-g@tH;;K-39Kw~(RV>pzfKTd-`psSWt zR#sLC8TA5VOw9;EkH=F~DrA0t{`~o4XSjY(h1D%}%Y0xh%Ev+d6{n61gJC9u# zT*Ox}skUkK`nB}%*Z;)O=1;7dn4kIL;+Ep)+VxQ|xq20hfl1J_5RETgwtU&r%-Wx=YFW{;>Jv~H8QJ#f zFlt}lF>L;IOr)-^v98V}h2?e2>*|(g>6*HDysjooFKei)Ygm@08{^C4@x~kIc$Th- zKVMfD$$)Cd8y`Y=CRe4Jf8Zj(RX z@L-lkd4t8iCLZy6EqF9yzM-r>y0_(vq+JdfTJ&MU5i1SaR)3A&U?^;v-;Mc)s()a^ zD^lkT+Vz7r{DP%b4UNHUe7IgVc_a=qw&2g>hqL;TI>^8x2dc(vp`~zjbyyOFI^2aU z^EN!-x4J77cO5uch0bfD*tU? z->Ovx4g1$QJS_AoJhC)OGq66jBXza9x;nD_szGNm!~Z=nkhRCP34fv5WXPSfgMfp8 zgMfp8gMfp8gMfp8gMfp8gMfp8gMfp8gMfp8gMfp8gMfp8gMfp8gMfp8gTVg>0{;sG C(6+q* literal 0 HcmV?d00001 diff --git a/examples/war/COMPILER_BUGS.md b/examples/war/COMPILER_BUGS.md new file mode 100644 index 0000000..441a36e --- /dev/null +++ b/examples/war/COMPILER_BUGS.md @@ -0,0 +1,435 @@ +# NEScript v0.1 — Compiler Bugs and Limitations Found While Building War + +This document captures bugs and limitations discovered while +building `examples/war.ne`. Each entry includes a minimal +reproduction, the symptom we observed, the root cause if known, +and a workaround we used in `examples/war/*.ne`. The intent is +to track these so they can be fixed in a future compiler pass — +once they are, the corresponding workarounds in `war/*.ne` +should be reverted to keep the example honest. + +--- + +## 1. Functions with more than 4 parameters silently corrupt the 5th+ + +### Symptom + +Calling a function with 5 or 6 parameters compiles cleanly, with +no warning or error, but at runtime the 5th and 6th parameter +values are silently replaced by garbage (typically the value of +parameter 3 or 4). Animations and state writes that depend on +those parameters behave as if zero was passed. + +### Reproduction + +```nescript +fun arm_fly(sx: u8, sy: u8, dxsign: u8, dysign: u8, card: u8, fu: u8) { + fly_x = sx + fly_y = sy + fly_dx_sign = dxsign + fly_dy_sign = dysign + fly_card = card // gets the value of dxsign instead! + fly_face_up = fu // gets the value of dxsign instead! +} + +fun caller() { + arm_fly(32, 64, 0, 0, 147, 1) + // After this call: + // fly_x = 32, fly_y = 64, fly_dx_sign = 0, fly_dy_sign = 0 + // fly_card = 0 (NOT 147) + // fly_face_up = 0 (NOT 1) +} +``` + +### Root cause + +`src/codegen/ir_codegen.rs` (around line 240) iterates through +`func.locals` and assigns the first 4 entries to zero-page +parameter slots `$04`-`$07`: + +```rust +for func in &ir.functions { + for (i, local) in func.locals.iter().enumerate() { + if i < func.param_count { + if i < 4 { + var_addrs.insert(local.var_id, 0x04 + i as u16); + ... + } + } else { + ... + } + } +} +``` + +The `if i < 4` guard silently drops the mapping for params 5+ +without inserting any RAM allocation for them. The corresponding +caller-side codegen for `Call` writes only the first four +arguments. Result: params 5 and 6 are never passed and the +callee reads stale memory from $04-$07 in their place. + +### Workaround used in `examples/war/` + +`arm_fly` is split: the four "arming" parameters stay in the +function signature, and `fly_card` / `fly_face_up` are written to +the global state directly at every call site instead. See +`war/play_state.ne` (`begin_draw_a` / `begin_draw_b`). + +### Fix proposal + +Two reasonable options: + +1. **Diagnose-only**: emit `E05XX too many parameters` when a + `fun` declaration has more than 4 params. This is the + smallest possible change and turns silent miscompiles into a + loud compile-time error. Should ship immediately even if + option 2 is also planned. + +2. **Spill to RAM**: extend the calling convention so params + beyond the first four are passed via dedicated RAM slots in + the callee's local frame. The caller-side `Call` codegen + would write those slots before `JSR`, the callee-side prologue + could leave them as-is. This grows the per-function RAM + footprint but lets users write any signature they like. + +--- + +## 1b. Function parameters with the same name in different functions share a VarId, which collides their zero-page slot mapping + +### Symptom + +Two unrelated functions whose parameters happen to be named the +same (e.g. both have a `card: u8` parameter, or both have an +`x: u8` parameter) end up reading parameters from the wrong +zero-page slot at runtime. One function reads `$04`, another +reads `$06`, a third reads `$05` — depending on the parameter's +*position* in whichever function is processed last by the +codegen. + +This is a much sneakier sibling of bug #1: rather than dropping +a parameter past the 4th slot, it silently reroutes parameter +reads to slots that hold completely unrelated values from the +caller. + +### Reproduction + +```nescript +// Function A: card is the 1st parameter, expected at $04 +fun push_back_a(card: u8) { + deck_a[deck_a_front] = card // reads from $06, not $04! + deck_a_count += 1 +} + +// Function B: card is the 3rd parameter, expected at $06 +fun draw_card_face(x: u8, y: u8, card: u8) { + // ... uses card normally ... +} +``` + +The IR lowering assigns `card` a single shared `VarId` because +its `var_map` is global across all functions. The codegen then +walks each function in turn, inserting `(VarId(card), $0X)` +mappings into a single global `var_addrs` `HashMap` — and +whichever function comes last in iteration order wins the +mapping. If `draw_card_face` is processed after `push_back_a`, +`VarId(card)` ends up mapped to `$06`, and `push_back_a` then +reads its `card` parameter from `$06` (which holds whatever the +caller was using as a third argument — typically junk). + +### Root cause + +`src/ir/lowering.rs::get_or_create_var` looks up names in +`self.var_map`, which is shared across the whole program: + +```rust +fn get_or_create_var(&mut self, name: &str) -> VarId { + if let Some(&id) = self.var_map.get(name) { + id + } else { + let id = VarId(self.next_var_id); + self.next_var_id += 1; + self.var_map.insert(name.to_string(), id); + id + } +} +``` + +`lower_function` calls `get_or_create_var(¶m.name)` for each +parameter, so two different functions both with a `card` +parameter resolve to the same `VarId`. Once that single `VarId` +flows into the codegen, the per-function "this is param index N +of function F" relationship is lost — there's only one global +mapping per `VarId`. + +### Workaround used in `examples/war/` + +Every parameter name in the war source is unique across the +entire program. Function-locals were already prefixed by +function (see bug #3); we extended the same scheme to params: +`push_back_a(pba_arg_card: u8)` instead of +`push_back_a(card: u8)`, etc. The wrapping `pba_card` / +`pbb_card` / `dcf_card` snapshots from bug #2 stay because they +also help with the bug-2 clobbering. + +### Fix proposal + +Two layers to fix in: + +1. **IR lowering**: give every function its own `var_map` for + parameters and locals. The global `var_map` should only hold + top-level `var` / `const` / `enum` symbols. + +2. **Codegen**: even after the IR fix, the global `var_addrs` + `HashMap` should grow a per-function dimension (one map per + `IrFunction`) so two different functions can independently + assign their own VarIds to overlapping zero-page slots. + +Either fix alone is probably enough; both together is robust. + +--- + +## 2. Function parameters share zero-page slots with nested calls — values clobbered across `JSR` + +### Symptom + +A function that takes parameters and then calls another function +sees its own parameters silently replaced by the inner call's +arguments. Any code path that reads the original parameter +*after* the inner call gets the wrong value. + +### Reproduction + +```nescript +fun draw_card_face(x: u8, y: u8, card: u8) { + var rank: u8 = card_rank(card) // x at $04 is now `card` + var suit: u8 = card_suit(card) // x at $04 is still `card` + // x is supposed to be 120 here, but it's actually `card` + var x1: u8 = x + 8 // computes card + 8, not 120 + 8 + draw Tileset at: (x, y) frame: ... // draws at x = card, not 120 +} +``` + +Concretely, calling `draw_card_face(120, 128, 0x93)` puts the +card sprite at `(0x93, 128)` — completely wrong. + +### Root cause + +Same allocator as bug #1: `func.locals[0..param_count]` are +mapped to `$04`, `$05`, `$06`, `$07`. The caller writes its own +arguments into the same zero-page slots before `JSR`, so the +caller's parameters at those slots get clobbered by the callee's +arguments. There is no save/restore wrapper around `JSR` and no +spill/reload pass to refresh the caller's parameters from a +backing copy. + +### Workaround used in `examples/war/` + +Every helper that takes parameters AND makes any nested function +call snapshots its parameters into fresh local variables at the +top of the function, then references the locals exclusively +throughout the body. See `war/render.ne::draw_card_face`, +`war/render.ne::draw_flying_card`, `war/deck.ne::push_back_a`, +`war/deck.ne::push_back_b`. + +### Fix proposal + +1. **Spill on entry**: at the top of every function body that + makes a call, copy `$04..$07` into per-function RAM slots and + rewrite all parameter reads to load from the RAM copies. + Equivalent to what users are doing manually today. + +2. **Smarter scheduling**: only spill a parameter slot if it's + live across a call site (CFG-aware liveness pass on params). + Same effect, less RAM cost for short helpers that never read + their params after calling out. + +Either fix would let users write straightforward function bodies +without having to remember the snapshot dance. + +--- + +## 3. Function-local variable names are in a flat global namespace + +### Symptom + +Two different functions cannot declare locals with the same +name. The compiler emits `E0501 duplicate declaration of ''` +even though the locals are in disjoint scopes. + +### Reproduction + +```nescript +fun foo() { + var i: u8 = 0 + while i < 10 { i += 1 } +} + +fun bar() { + var i: u8 = 0 // E0501 duplicate declaration of 'i' + while i < 5 { i += 1 } +} +``` + +### Root cause + +`src/analyzer/mod.rs::register_var` inserts every `var` +declaration into a single `self.symbols` map keyed only on the +variable's name, with no qualification by function or block: + +```rust +fn register_var(&mut self, var: &VarDecl) { + if self.symbols.contains_key(&var.name) { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0501, + format!("duplicate declaration of '{}'", var.name), + var.span, + )); + return; + } + ... +} +``` + +`check_statement` calls `register_var` for every `Statement::VarDecl` +encountered while walking function bodies, so all locals across +all functions and all nested blocks land in the same namespace. + +### Workaround used in `examples/war/` + +Every function-local variable is prefixed with a short tag +identifying its enclosing function (e.g. `dfa_card` in +`draw_front_a`, `pba_slot` in `push_back_a`, +`dwp_px` in `draw_word_player`). This makes long files harder to +read but is fully mechanical. + +### Fix proposal + +Rework `register_var` to maintain a stack of scopes (one per +function body, one per nested block). Each `Statement::VarDecl` +inserts into the current scope. Lookup walks the stack from +innermost to outermost. The existing global symbol table is +unchanged for top-level globals / consts / fun names; only +function-locals shift to the scoped table. + +A smaller intermediate fix: keep the flat table but qualify +each local's stored name as `::` so the global +table sees unique entries even when source names collide. + +--- + +## 4. Per-frame sprite-per-scanline limit is invisible to user code + +### Symptom + +Drawing more than 8 sprites whose Y rectangles intersect a +single scanline causes the NES PPU to silently drop the excess +sprites past the 8th in OAM order. There's no compile-time +detection and no runtime warning — letters or tiles just don't +render. + +### Reproduction + +```nescript +// 9 letters all on the same Y row: +draw_letter(0, 100, 0) +draw_letter(8, 100, 1) +draw_letter(16, 100, 2) +draw_letter(24, 100, 3) +draw_letter(32, 100, 4) +draw_letter(40, 100, 5) +draw_letter(48, 100, 6) +draw_letter(56, 100, 7) +draw_letter(64, 100, 8) // this one will not render +``` + +### Root cause + +This is a real NES hardware constraint, not a compiler bug. +However, because NEScript's `draw` allocator is purely +sequential, the compiler cannot warn even when it has all the +information needed to know the layout would overflow. + +### Workaround used in `examples/war/` + +We staggered text rows. The title screen's "WAR / CARD GAME / +0 PLAYER / 1 PLAYER / 2 PLAYER" layout sits each row at a +different y so no scanline carries more than 7 sprites; the +victory screen's "PLAYER X / WINS" wraps after the player letter +for the same reason. + +### Fix proposal + +Two complementary improvements: + +1. **Static analyzer pass**: walk the IR for each frame handler, + collect the set of `(x, y)` literal pairs feeding `draw` + ops within the same basic block, and emit `W01XX` if any + scanline (8-px row) would have > 8 sprites. Only catches the + literal case but that's the most common. + +2. **Sprite-cycling runtime helper**: a `cycle_sprites()` + intrinsic that rotates OAM order each frame so the same + sprites get dropped on different frames, producing a flicker + instead of a permanent dropout. Standard NES technique. + +--- + +## 5. The `inline` keyword is a hint and is silently ignored for short functions + +### Symptom + +Marking a tiny function `inline fun` does not always inline it. +The compiler still emits a real `JSR` with full parameter +passing through `$04`-`$07`, which means the inlining doesn't +escape the bug-2 parameter clobbering. + +### Reproduction + +```nescript +inline fun card_rank(card: u8) -> u8 { + return card >> 4 +} +``` + +The asm dump shows `JSR __ir_fn_card_rank` at every call site — +the function was not inlined. + +### Root cause + +(Inferred — would need to confirm by reading the inliner pass.) +The optimizer's inlining pass has a size threshold or a heuristic +that prevents inlining in some contexts even when the function +is marked `inline`. There's no diagnostic emitted when the hint +is declined. + +### Workaround used in `examples/war/` + +None — we just live with the JSR overhead and the bug-2 fallout. + +### Fix proposal + +1. **Promote `inline` to a hard contract**: when `inline` is + present, always inline (or emit `W01XX` if it cannot be + inlined for a structural reason like recursion). + +2. **Optional dump**: add `--dump-inliner` to print which + `inline fun` declarations were inlined and which weren't, + with the reason. + +--- + +## Verification path after fixes + +Once any of the bugs above are fixed in the compiler, the +corresponding workarounds in `examples/war/*.ne` should be +reverted in the same PR so: + +- The example demonstrates idiomatic code, not workaround code. +- The PR's diff visibly proves the fix works end-to-end (the + workaround removal would otherwise be a silent regression). +- The committed `examples/war.nes` rebuilds byte-identically to + the reverted source, which the pre-commit hook enforces. + +The relevant workaround sites are catalogued in each bug's +"Workaround used" section above; grep for the prefix tags +(`dcf_`, `dfa_`, `pba_`, `dwp_`, …) to find them all. diff --git a/examples/war/assets.ne b/examples/war/assets.ne new file mode 100644 index 0000000..b0c2cb0 --- /dev/null +++ b/examples/war/assets.ne @@ -0,0 +1,856 @@ +// war/assets.ne — the one big Tileset sprite block. +// +// Every custom 8×8 CHR tile in the game is stacked vertically inside +// a single `sprite Tileset { pixels: [...] }` declaration. Stacking +// them 1-tile-wide × N-tiles-tall means the parser splits each tile +// into consecutive tile indices in reading order, so tile index N in +// a `draw Tileset frame: N` call picks the N'th 8×8 block. +// +// Every constant in constants.ne points at a position in this list. +// If you add or remove a tile, update BOTH this file AND the matching +// constant range. +// +// The colour vocabulary (per the palette declaration in war.ne): +// +// . transparent +// 1 red (palette index 1 — hearts, diamonds, accents) +// 2 white (palette index 2 — card body, letters) +// 3 black (palette index 3 — outlines, spades, clubs, text) +// +// All tiles below use this 4-colour alphabet. Red is only used for +// red suits; black is used for text, outlines, and black suits. + +sprite Tileset { + pixels: [ + // ── 01: card frame top-left corner ─────────────── + // Rounded top-left corner of a card body. Black outline + // two pixels thick on the exterior, white interior so + // rank glyphs drawn with black pixels read cleanly on + // top. The interior corner is pure white so the rank + // tile (drawn next to it) overpaints naturally. + "..333333", + ".3222222", + ".3222222", + "3222....", + "3222....", + "3222....", + "3222....", + "3222....", + // ── 02: card frame top-right corner ────────────── + "333333..", + "2222223.", + "2222223.", + "....2223", + "....2223", + "....2223", + "....2223", + "....2223", + // ── 03: card frame bottom-left corner ──────────── + "3222....", + "3222....", + "3222....", + "3222....", + "3222....", + ".3222222", + ".3222222", + "..333333", + // ── 04: card frame bottom-right corner ─────────── + "....2223", + "....2223", + "....2223", + "....2223", + "....2223", + "2222223.", + "2222223.", + "333333..", + // ── 05: card frame blank cell — left half ──────── + // Pure white interior with a left black edge (for the + // card's left border). Used for the blank middle / + // bottom of any card face that doesn't carry art. + "32222222", + "32222222", + "32222222", + "32222222", + "32222222", + "32222222", + "32222222", + "32222222", + // ── 06: card frame blank cell — right half ─────── + "22222223", + "22222223", + "22222223", + "22222223", + "22222223", + "22222223", + "22222223", + "22222223", + // ── 07: card back top-left ─────────────────────── + // The back is a black-and-white diamond lattice, + // painted as a 2×2 repeating block. Every card back + // uses all four corners tiled into a 16×16 / 16×24 + // block. + "..333333", + ".3333333", + ".3322333", + "333.2.33", + "33.222.3", + "33.2.233", + "3322.333", + "3333.333", + // ── 08: card back top-right ────────────────────── + "333333..", + "3333333.", + "33322.33", + "33.2.333", + "3.222.33", + "33.2.333", + "333.2233", + "3333.333", + // ── 09: card back bottom-left ──────────────────── + "3333.333", + "3322.333", + "33.2.233", + "33.222.3", + "333.2.33", + ".3322333", + ".3333333", + "..333333", + // ── 10: card back bottom-right ─────────────────── + "3333.333", + "333.2233", + "33.2.333", + "3.222.33", + "33.2.333", + "33322.33", + "3333333.", + "333333..", + // ── 11: rank A (Ace) ───────────────────────────── + // Bold black glyph on a white card body. The rank is + // drawn 6×6 px and centred horizontally in the 8×8 tile. + // The card body fills index 2 (white) so the glyph reads + // as black-on-white when the card frame's outline tiles + // wrap it. + "22222222", + "22.33.22", + "2.3333.2", + "2.3..3.2", + "2.3333.2", + "2.3..3.2", + "2.3..3.2", + "22222222", + // ── 12: rank 2 ─────────────────────────────────── + "22222222", + "2.3333.2", + "2....3.2", + "22.33.22", + "2.3...22", + "2.3...22", + "2.3333.2", + "22222222", + // ── 13: rank 3 ─────────────────────────────────── + "22222222", + "2.3333.2", + "22...3.2", + "22.33.22", + "22...3.2", + "2....3.2", + "2.3333.2", + "22222222", + // ── 14: rank 4 ─────────────────────────────────── + "22222222", + "2.3..3.2", + "2.3..3.2", + "2.3..3.2", + "2.3333.2", + "22...3.2", + "22...3.2", + "22222222", + // ── 15: rank 5 ─────────────────────────────────── + "22222222", + "2.3333.2", + "2.3...22", + "2.3333.2", + "22...3.2", + "22...3.2", + "2.3333.2", + "22222222", + // ── 16: rank 6 ─────────────────────────────────── + "22222222", + "2.3333.2", + "2.3...22", + "2.3333.2", + "2.3..3.2", + "2.3..3.2", + "2.3333.2", + "22222222", + // ── 17: rank 7 ─────────────────────────────────── + "22222222", + "2.3333.2", + "22...3.2", + "22..3.22", + "22.3..22", + "22.3..22", + "22.3..22", + "22222222", + // ── 18: rank 8 ─────────────────────────────────── + "22222222", + "2.3333.2", + "2.3..3.2", + "2.3333.2", + "2.3..3.2", + "2.3..3.2", + "2.3333.2", + "22222222", + // ── 19: rank 9 ─────────────────────────────────── + "22222222", + "2.3333.2", + "2.3..3.2", + "2.3..3.2", + "2.3333.2", + "22...3.2", + "2.3333.2", + "22222222", + // ── 20: rank 10 (condensed "1" + "0") ───────────── + // "10" squeezed into an 8x8 tile. The "1" takes columns + // 0-1, a gap, the "0" takes columns 4-7. + "22222222", + "2.3.33.2", + "233.3..3", + "2.3.3..3", + "2.3.3..3", + "2.3.3..3", + "2.3.33.2", + "22222222", + // ── 21: rank J ─────────────────────────────────── + "22222222", + "222.33.2", + "222..3.2", + "222..3.2", + "222..3.2", + "22.3.3.2", + "22.333.2", + "22222222", + // ── 22: rank Q ─────────────────────────────────── + "22222222", + "2.3333.2", + "2.3..3.2", + "2.3..3.2", + "2.3.33.2", + "2.3333.2", + "22.33.32", + "22222222", + // ── 23: rank K ─────────────────────────────────── + "22222222", + "2.3..3.2", + "2.3.3.22", + "2.33..22", + "2.3.3.22", + "2.3..3.2", + "2.3..3.2", + "22222222", + // ── 24: small spade ♠ ──────────────────────────── + // 6x6 centred pip on a white 8x8 tile. + "22222222", + "2222.222", + "222.3.22", + "22.333.2", + "2.33333.", + "2.33333.", + "222.3.22", + "222333.2", + // ── 25: small heart ♥ ──────────────────────────── + // Red pip on white. + "22222222", + "22.11.22", + "2.1111.2", + "2.1111.2", + "2.1111.2", + "22.11.22", + "222.1.22", + "22222222", + // ── 26: small diamond ♦ ────────────────────────── + "22222222", + "22221222", + "2221.122", + "221...12", + "221...12", + "2221.122", + "22221222", + "22222222", + // ── 27: small club ♣ ───────────────────────────── + "22222222", + "2222.222", + "2223332.", + "2223332.", + "22.333.2", + "2.33333.", + "22.3.3.2", + "222333.2", + // ── 28: big spade — left half ──────────────────── + // The big centre-pip is drawn 16×8: two tiles side by + // side. Each suit's (left half, right half) pair is + // authored explicitly so the centre art can be bolder + // and more symmetric than the corner pips. + "2222222.", + "222222.3", + "22222.33", + "2222.333", + "222.3333", + "22.33333", + "2.333333", + ".3333333", + // ── 29: big heart — left half ──────────────────── + "2222.111", + "222.1111", + "22.11111", + "22.11111", + "22.11111", + "222.1111", + "2222.111", + "22222.11", + // ── 30: big diamond — left half ────────────────── + "22222222", + "22222.12", + "2222.112", + "222.1112", + "22.11112", + "2.111112", + "22.11112", + "222.1112", + // ── 31: big club — left half ───────────────────── + "2222.333", + "2222.333", + "222.3333", + "22.33333", + "2.333333", + "2.333333", + "22.333.3", + "2223.333", + // ── 32: big spade — right half ─────────────────── + ".3333333", + "2.333333", + "22.33333", + "222.3333", + "2222.333", + "22222.33", + "222222.3", + "2222222.", + // ── 33: big heart — right half ─────────────────── + "111.2222", + "1111.222", + "11111.22", + "11111.22", + "11111.22", + "1111.222", + "111.2222", + "11.22222", + // ── 34: big diamond — right half ───────────────── + "22222222", + "21.22222", + "211.2222", + "2111.222", + "21111.22", + "211111.2", + "21111.22", + "2111.222", + // ── 35: big club — right half ──────────────────── + "333.2222", + "333.2222", + "3333.222", + "33333.22", + "333333.2", + "333333.2", + "3.333.22", + "333.3222", + // ── 36-61: alphabet A..Z (8x8, white on transparent) ─ + // Bold sans-serif letters drawn with 2-pixel strokes so + // they still read over the felt at NES resolution. Every + // letter uses palette index 2 (white) on a transparent + // background. Letters are 6 wide, centred horizontally + // in the 8-wide tile with a 1-pixel top/bottom margin. + // A + "........", + "..2222..", + ".22..22.", + ".22..22.", + ".222222.", + ".22..22.", + ".22..22.", + "........", + // B + "........", + ".22222..", + ".22..22.", + ".22222..", + ".22..22.", + ".22..22.", + ".22222..", + "........", + // C + "........", + "..22222.", + ".22..22.", + ".22.....", + ".22.....", + ".22..22.", + "..22222.", + "........", + // D + "........", + ".2222...", + ".22.22..", + ".22..22.", + ".22..22.", + ".22.22..", + ".2222...", + "........", + // E + "........", + ".222222.", + ".22.....", + ".22222..", + ".22.....", + ".22.....", + ".222222.", + "........", + // F + "........", + ".222222.", + ".22.....", + ".22222..", + ".22.....", + ".22.....", + ".22.....", + "........", + // G + "........", + "..22222.", + ".22.....", + ".22.222.", + ".22..22.", + ".22..22.", + "..2222..", + "........", + // H + "........", + ".22..22.", + ".22..22.", + ".222222.", + ".22..22.", + ".22..22.", + ".22..22.", + "........", + // I + "........", + "..2222..", + "...22...", + "...22...", + "...22...", + "...22...", + "..2222..", + "........", + // J + "........", + "....222.", + ".....22.", + ".....22.", + ".....22.", + ".22..22.", + "..2222..", + "........", + // K + "........", + ".22..22.", + ".22.22..", + ".2222...", + ".22.22..", + ".22..22.", + ".22..22.", + "........", + // L + "........", + ".22.....", + ".22.....", + ".22.....", + ".22.....", + ".22.....", + ".222222.", + "........", + // M + "........", + ".22..22.", + ".222222.", + ".222222.", + ".22..22.", + ".22..22.", + ".22..22.", + "........", + // N + "........", + ".22..22.", + ".222.22.", + ".222222.", + ".22.222.", + ".22..22.", + ".22..22.", + "........", + // O + "........", + "..2222..", + ".22..22.", + ".22..22.", + ".22..22.", + ".22..22.", + "..2222..", + "........", + // P + "........", + ".22222..", + ".22..22.", + ".22..22.", + ".22222..", + ".22.....", + ".22.....", + "........", + // Q + "........", + "..2222..", + ".22..22.", + ".22..22.", + ".22.222.", + ".22.22..", + "..222.2.", + "........", + // R + "........", + ".22222..", + ".22..22.", + ".22..22.", + ".22222..", + ".22.22..", + ".22..22.", + "........", + // S + "........", + "..22222.", + ".22.....", + "..2222..", + ".....22.", + ".....22.", + ".22222..", + "........", + // T + "........", + ".222222.", + "...22...", + "...22...", + "...22...", + "...22...", + "...22...", + "........", + // U + "........", + ".22..22.", + ".22..22.", + ".22..22.", + ".22..22.", + ".22..22.", + "..2222..", + "........", + // V + "........", + ".22..22.", + ".22..22.", + ".22..22.", + ".22..22.", + "..2222..", + "...22...", + "........", + // W + "........", + ".22..22.", + ".22..22.", + ".22..22.", + ".222222.", + ".222222.", + ".22..22.", + "........", + // X + "........", + ".22..22.", + "..2222..", + "...22...", + "...22...", + "..2222..", + ".22..22.", + "........", + // Y + "........", + ".22..22.", + ".22..22.", + "..2222..", + "...22...", + "...22...", + "...22...", + "........", + // Z + "........", + ".222222.", + ".....22.", + "....22..", + "...22...", + "..22....", + ".222222.", + "........", + // ── 62-71: digits 0..9 (bold) ────────────────── + // 0 + "........", + "..2222..", + ".22..22.", + ".22..22.", + ".22..22.", + ".22..22.", + "..2222..", + "........", + // 1 + "........", + "...22...", + "..222...", + ".2.22...", + "...22...", + "...22...", + "..2222..", + "........", + // 2 + "........", + "..2222..", + ".22..22.", + ".....22.", + "....22..", + "..22....", + ".222222.", + "........", + // 3 + "........", + "..2222..", + ".22..22.", + "....22..", + ".....22.", + ".22..22.", + "..2222..", + "........", + // 4 + "........", + "....22..", + "...222..", + "..2.22..", + ".22.22..", + ".222222.", + "....22..", + "........", + // 5 + "........", + ".222222.", + ".22.....", + ".22222..", + ".....22.", + ".22..22.", + "..2222..", + "........", + // 6 + "........", + "..2222..", + ".22.....", + ".22222..", + ".22..22.", + ".22..22.", + "..2222..", + "........", + // 7 + "........", + ".222222.", + ".....22.", + "....22..", + "...22...", + "..22....", + "..22....", + "........", + // 8 + "........", + "..2222..", + ".22..22.", + "..2222..", + ".22..22.", + ".22..22.", + "..2222..", + "........", + // 9 + "........", + "..2222..", + ".22..22.", + ".22..22.", + "..22222.", + ".....22.", + "..2222..", + "........", + // ── 72: cursor (▶) ──────────────────────────── + "........", + "..2.....", + "..22....", + "..222...", + "..222...", + "..22....", + "..2.....", + "........", + // ── 73: tiny heart (victory marker) ─────────── + "........", + ".11..11.", + "11111111", + "11111111", + ".111111.", + "..1111..", + "...11...", + "........", + // ── 74: single dot (stack marker / spacer) ──── + "........", + "...22...", + "..2222..", + "..2222..", + "..2222..", + "..2222..", + "...22...", + "........", + // ── 75: felt background fill ───────────────── + // Subtle cross-hatch on the felt. When the background + // layer renders this tile through bg sub-palette 0 + // (universal dk_green + forest/green/mint), index 0 + // becomes dk_green and index 3 becomes mint — so the + // tile shows as dk_green felt with sparse mint flecks. + // The same CHR rendered as a sprite would show as + // transparent + black, which we never do. + "........", + "....3...", + "........", + "..3.....", + "........", + "......3.", + "........", + "...3....", + // ── 76-87: BIG WAR title letters ───────────── + // Each letter is 16×16 = a 2x2 block of tiles. The + // letters are designed bold and chunky so the title + // reads from across the room. Each tile draws a + // quadrant of the letter using palette index 2 (white + // when rendered via sp0, white when rendered via the + // bg cream sub-palette). + // + // 76: BIG W top-left + "22....22", + "22....22", + "22....22", + "22....22", + "22....22", + "22....22", + "22....22", + "22....22", + // 77: BIG W top-right + "22....22", + "22....22", + "22....22", + "22....22", + "22....22", + "22....22", + "22....22", + "22....22", + // 78: BIG W bottom-left + "22....22", + "22....22", + "22.22.22", + "22.22.22", + "22.22.22", + "2222.222", + "2222.222", + ".22..22.", + // 79: BIG W bottom-right + "22....22", + "22....22", + "22.22.22", + "22.22.22", + "22.22.22", + "222.2222", + "222.2222", + ".22..22.", + // 80: BIG A top-left + "....2222", + "...22222", + "..222222", + ".2222.22", + "2222..22", + "2222..22", + "222....2", + "222....2", + // 81: BIG A top-right + "2222....", + "22222...", + "222222..", + "22.2222.", + "22..2222", + "22..2222", + "2....222", + "2....222", + // 82: BIG A bottom-left + "22222222", + "22222222", + "22222222", + "222....2", + "222....2", + "222....2", + "222....2", + "222....2", + // 83: BIG A bottom-right + "22222222", + "22222222", + "22222222", + "2....222", + "2....222", + "2....222", + "2....222", + "2....222", + // 84: BIG R top-left + "22222222", + "22222222", + "222....2", + "222....2", + "222....2", + "222....2", + "22222222", + "22222222", + // 85: BIG R top-right + "2222....", + "22222...", + "..222...", + "..222...", + "..222...", + "..2222..", + ".22222..", + "22222...", + // 86: BIG R bottom-left + "22222222", + "22222222", + "222.2222", + "222..222", + "222...22", + "222...22", + "222....2", + "222....2", + // 87: BIG R bottom-right + "22......", + "222.....", + "2222....", + "22222...", + "222222..", + "2222222.", + "22222222", + ".2222222" + ] +} diff --git a/examples/war/audio.ne b/examples/war/audio.ne new file mode 100644 index 0000000..94f5140 --- /dev/null +++ b/examples/war/audio.ne @@ -0,0 +1,84 @@ +// war/audio.ne — every sfx + music declaration in the game. +// +// The NEScript audio driver is only linked in when user code +// contains at least one `play` / `start_music` / `stop_music` +// statement. That's always true for this game, so everything +// declared here ends up in PRG ROM. +// +// Channel budget: +// pulse 1 — sfx (FlipCard, CheerA, CheerB, WarFlash) +// pulse 2 — music (TitleTheme and the builtin fanfare) +// triangle — unused (reserved for later) +// noise — ThudDown bury sfx + +// ── Sound effects ────────────────────────────────────────── + +// Sharp descending click for a card flip / draw. Kept short so +// repeated clicks during the deal animation don't overlap. +sfx FlipCard { + duty: 1 + pitch: 0x28 + envelope: [12, 9, 5, 2] +} + +// Cheerful ascending arpeggio when player A wins a round. The +// per-frame pitch envelope sweeps through three notes so the +// effect actually sounds like a melody rather than a beep. +sfx CheerA { + duty: 2 + pitch: [0x50, 0x50, 0x50, 0x40, 0x40, 0x40, 0x30, 0x30, 0x30, 0x28] + volume: [14, 13, 12, 14, 13, 12, 14, 13, 12, 10] +} + +// Same shape but descending for player B — immediately +// distinguishable from CheerA without reading the screen. +sfx CheerB { + duty: 2 + pitch: [0x30, 0x30, 0x30, 0x40, 0x40, 0x40, 0x50, 0x50, 0x50, 0x60] + volume: [14, 13, 12, 14, 13, 12, 14, 13, 12, 10] +} + +// Exciting two-part pitch sweep for the "WAR!" tie-break. A +// rising trill followed by a descending burst. The volume ramps +// loud-soft-loud to really stand out from the calmer round sfx. +sfx WarFlash { + duty: 3 + pitch: [0x80, 0x60, 0x40, 0x30, 0x20, 0x20, 0x30, 0x40, 0x60, 0x80, 0x60, 0x40, 0x20, 0x20, 0x20, 0x20] + volume: [15, 13, 11, 9, 8, 10, 12, 14, 15, 13, 11, 9, 7, 5, 3, 1] +} + +// Low noise thump for each card buried during a war. +sfx ThudDown { + channel: noise + pitch: 8 + volume: [15, 11, 7, 3] +} + +// ── Music ────────────────────────────────────────────────── + +// Brisk 4/4 march on pulse 2 for the title screen. The pattern is +// a military-style tonic-dominant alternation (C - G - C - G) with +// a quick triplet pickup into each down-beat, evoking the rolling +// snare of a war drum. Every note is short and staccato so the +// melody feels like it's being played on a single high-pitched +// fife over an implied drum line. +// +// Four bars (two repeats of a two-bar phrase) that loop +// seamlessly. +music TitleTheme { + duty: 2 + volume: 10 + repeat: true + tempo: 8 + notes: [ + // bar 1 — C major down-beat with pickup triplet + G4 4, G4 4, C5 8, G4 8, E4 8, C4 8, + // bar 2 — dominant with rising triplet + G4 4, G4 4, G4 8, C5 8, E5 8, G4 8, + // bar 3 — tonic inversion, military flourish + C5 4, G4 4, E5 8, C5 8, G4 8, E4 8, + // bar 4 — resolution back to tonic + G4 4, G4 4, C5 8, G4 8, C4 16, + rest 8 + ] +} diff --git a/examples/war/background.ne b/examples/war/background.ne new file mode 100644 index 0000000..246c773 --- /dev/null +++ b/examples/war/background.ne @@ -0,0 +1,57 @@ +// war/background.ne — the static 32×30 nametable loaded at reset. +// +// NEScript loads the *first* declared `background` into nametable 0 +// before rendering is enabled, so whatever this file declares is +// visible on frame 0 of the ROM. We use a single felt-table +// background: a solid dark-green field for the play area. All UI +// (title banner, "PRESS A", win banners, etc.) is drawn on top via +// sprites so we never pay the cost of a full mid-frame nametable +// swap. +// +// Tile 0 is the linker's builtin smiley — we don't want that in the +// game, but it's safe to leave in the top-left corner because the +// whole nametable is painted with a blank tile. Use the legend's +// `.` entry to map every visible cell to tile 0 then rely on +// sub-palette 0 (forest/green/mint) making it look like felt. +// +// The `palette_map:` field is omitted on purpose: every attribute +// byte defaults to 0, which selects bg sub-palette 0 for every +// metatile. That's the felt palette. + +background Felt { + legend { + ".": 75 // TILE_FELT_BG — sparse mint flecks on dk_green + } + map: [ + "................................", + "................................", + "................................", + "................................", + "................................", + "................................", + "................................", + "................................", + "................................", + "................................", + "................................", + "................................", + "................................", + "................................", + "................................", + "................................", + "................................", + "................................", + "................................", + "................................", + "................................", + "................................", + "................................", + "................................", + "................................", + "................................", + "................................", + "................................", + "................................", + "................................" + ] +} diff --git a/examples/war/compare.ne b/examples/war/compare.ne new file mode 100644 index 0000000..319cc65 --- /dev/null +++ b/examples/war/compare.ne @@ -0,0 +1,30 @@ +// war/compare.ne — card rank/suit extraction and round resolution. +// +// Cards are packed as `(rank << 4) | suit`; the extractors are +// one shift or one mask each. +// +// Parameter names are unique across the entire program — see +// COMPILER_BUGS.md §1b for why same-named params in different +// functions silently corrupt each other through shared VarIds. + +inline fun card_rank(crk_c: u8) -> u8 { + return crk_c >> 4 +} + +inline fun card_suit(csu_c: u8) -> u8 { + return csu_c & 0x0F +} + +// Compare two cards by rank. Returns: +// 1 if A wins, 2 if B wins, 0 if they tie +fun compare_cards(cmp_a: u8, cmp_b: u8) -> u8 { + var cmp_ra: u8 = card_rank(cmp_a) + var cmp_rb: u8 = card_rank(cmp_b) + if cmp_ra > cmp_rb { + return 1 + } + if cmp_rb > cmp_ra { + return 2 + } + return 0 +} diff --git a/examples/war/constants.ne b/examples/war/constants.ne new file mode 100644 index 0000000..722d096 --- /dev/null +++ b/examples/war/constants.ne @@ -0,0 +1,170 @@ +// war/constants.ne — gameplay and layout constants. +// +// Everything that feeds the game's positional layout, animation +// timing, phase machine, or card encoding lives here so one central +// edit can retune the whole game. No code, just `const` entries. + +// ── Card encoding ───────────────────────────────────────── +// +// Each card in a deck is packed into one u8: +// +// high nibble = rank, 1..13 (A=1, 2..10, J=11, Q=12, K=13) +// low nibble = suit, 0..3 (0=♠, 1=♥, 2=♦, 3=♣) +// +// Rank nibble lives in bits 7..4 and extracts cleanly via >>4; +// suit nibble lives in bits 3..0 and extracts via &0x0F. No +// division, no multiplication. +const SUIT_SPADE: u8 = 0 +const SUIT_HEART: u8 = 1 +const SUIT_DIAMOND: u8 = 2 +const SUIT_CLUB: u8 = 3 + +const RANK_ACE: u8 = 1 +const RANK_JACK: u8 = 11 +const RANK_QUEEN: u8 = 12 +const RANK_KING: u8 = 13 + +const DECK_SIZE: u8 = 52 // standard 52-card deck +const HALF_DECK: u8 = 26 // starting count per player + +// ── Tile index base pointers into the Tileset sprite ───── +// +// The Tileset sprite (declared in war/assets.ne) stacks every +// custom tile vertically in reading order, so tile index N in a +// `draw Tileset frame: N` call picks the N'th 8×8 block. +// +// Tile 0 is reserved by the linker for the builtin smiley, so +// every custom tile starts at 1. The ranges below must match the +// order the Tileset body lists them in — if you move a tile, +// move its constant too. + +// Card frame (shared across every face-up card) +const TILE_FRAME_TL: u8 = 1 // top-left corner with inset +const TILE_FRAME_TR: u8 = 2 // top-right corner +const TILE_FRAME_BL: u8 = 3 // bottom-left corner +const TILE_FRAME_BR: u8 = 4 // bottom-right corner +const TILE_FRAME_BLANK_L: u8 = 5 // left-half blank cell (white body) +const TILE_FRAME_BLANK_R: u8 = 6 // right-half blank cell + +// Card back — 4 tiles arranged as a 2x2 repeating diamond lattice +const TILE_BACK_TL: u8 = 7 +const TILE_BACK_TR: u8 = 8 +const TILE_BACK_BL: u8 = 9 +const TILE_BACK_BR: u8 = 10 + +// Rank glyphs: one tile per rank, indexed by RANK_TILE_BASE + rank - 1 +// so rank 1 (Ace) lands at RANK_TILE_BASE + 0. +const TILE_RANK_BASE: u8 = 11 // 13 tiles: A, 2..10, J, Q, K + +// Small suit glyphs for the card corner, one per suit. +const TILE_SUIT_SMALL_BASE: u8 = 24 // 4 tiles: ♠ ♥ ♦ ♣ + +// Big centre-pip, authored as a 16×8 pair per suit (left half + right half). +const TILE_PIP_L_BASE: u8 = 28 // 4 tiles +const TILE_PIP_R_BASE: u8 = 32 // 4 tiles + +// Alphanumerics (8×8) used for all on-screen text that lives on the +// sprite layer. Letters A-Z then digits 0-9, contiguous. +const TILE_LETTER_BASE: u8 = 36 // 26 letters: A=+0, Z=+25 +const TILE_DIGIT_BASE: u8 = 62 // 10 digits: 0=+0, 9=+9 + +// UI bits +const TILE_CURSOR: u8 = 72 // right-pointing arrow for menus +const TILE_HEART_TINY: u8 = 73 // tiny pip used as a victory marker +const TILE_DOT: u8 = 74 // single 8x8 card stack marker +const TILE_FELT_BG: u8 = 75 // subtle felt cross-hatch (bg fill) + +// Big WAR title letters — each letter is a 2x2 block of tiles +// addressed by its quadrant. +const TILE_BIG_W_TL: u8 = 76 +const TILE_BIG_W_TR: u8 = 77 +const TILE_BIG_W_BL: u8 = 78 +const TILE_BIG_W_BR: u8 = 79 +const TILE_BIG_A_TL: u8 = 80 +const TILE_BIG_A_TR: u8 = 81 +const TILE_BIG_A_BL: u8 = 82 +const TILE_BIG_A_BR: u8 = 83 +const TILE_BIG_R_TL: u8 = 84 +const TILE_BIG_R_TR: u8 = 85 +const TILE_BIG_R_BL: u8 = 86 +const TILE_BIG_R_BR: u8 = 87 + +// ── Screen-space layout ────────────────────────────────── +// +// Positions are in pixels. The screen is 256×240. +// Cards are 16 wide × 24 tall (2 cols × 3 rows of 8×8 tiles). +const CARD_W: u8 = 16 +const CARD_H: u8 = 24 + +// Decks sit on the upper half of the play area; face-up cards sit +// on the lower half so the two bands never share scanlines. +// +// The X and Y deltas are deliberately exactly 64 px so the +// per-frame step animation (FLY_STEP * FRAMES_FLY = 4 * 16 = 64) +// lands a flying card exactly on its destination — no rounding, +// no overshoot, no need to clamp at the end. +// +// DECK_A_X -> PLAY_A_X = 32 -> 96 (Δ = +64) +// DECK_B_X -> PLAY_B_X = 208 -> 144 (Δ = -64) +// DECK_Y -> PLAY_Y = 64 -> 128 (Δ = +64) +const DECK_Y: u8 = 64 // top edge of deck card-back sprite +const PLAY_Y: u8 = 128 // top edge of face-up card sprite + +const DECK_A_X: u8 = 32 // left deck +const DECK_B_X: u8 = 208 // right deck +const PLAY_A_X: u8 = 96 // centre-left face-up slot +const PLAY_B_X: u8 = 144 // centre-right face-up slot + +// War banner is centred horizontally in the middle of the screen. +// Coordinates are the top-left of the first banner sprite. +const BANNER_X: u8 = 104 +const BANNER_Y: u8 = 96 + +// HUD card counts sit above each deck. +const COUNT_A_X: u8 = 32 +const COUNT_B_X: u8 = 208 +const COUNT_Y: u8 = 56 + +// ── Animation timing ───────────────────────────────────── +// +// Every animation uses a power-of-two frame count so the lerp +// (delta * t / FRAMES) can be compiled into a shift-right by the +// optimizer, avoiding the software multiply/divide warning. +const FRAMES_FLY: u8 = 16 // card-draw and win-return slides +const FRAMES_BURY: u8 = 8 // faster bury animation during a war +const FRAMES_REVEAL:u8 = 32 // held pause after both cards are up +const FRAMES_BANNER:u8 = 48 // "WAR!" banner dwell +const FRAMES_DEAL_STEP: u8 = 4 // one dealt card every N frames + +// Title auto-advance for the headless jsnes harness: the menu +// confirms "0 PLAYERS" automatically after this many frames of no +// input so the golden capture at frame 180 always lands in +// gameplay. +const TITLE_AUTO_FRAMES: u8 = 45 + +// CPU "thinking" delay before it draws a card. +const CPU_THINK_FRAMES: u8 = 20 + +// Victory auto-return timer. +const VICTORY_LINGER_FRAMES: u8 = 180 + +// ── Phase values for the Playing state's inner machine ─── +// +// A plain `enum` would collide with other `Title` / `Victory` / +// etc identifiers, so we use explicit constants instead. +const P_WAIT_A: u8 = 0 +const P_FLY_A: u8 = 1 +const P_WAIT_B: u8 = 2 +const P_FLY_B: u8 = 3 +const P_REVEAL: u8 = 4 +const P_RESOLVE: u8 = 5 +const P_WIN_A: u8 = 6 +const P_WIN_B: u8 = 7 +const P_WAR_BANNER:u8 = 8 +const P_WAR_BURY: u8 = 9 +const P_CHECK: u8 = 10 + +// Game-mode bookkeeping: 0 / 1 / 2 players selected from the title. +const MODE_CPU_VS_CPU: u8 = 0 +const MODE_HUMAN_VS_CPU: u8 = 1 +const MODE_HUMAN_VS_HUMAN: u8 = 2 diff --git a/examples/war/deal_state.ne b/examples/war/deal_state.ne new file mode 100644 index 0000000..5ccc63b --- /dev/null +++ b/examples/war/deal_state.ne @@ -0,0 +1,88 @@ +// war/deal_state.ne — the Deal state. +// +// Shuffles the deck on entry, then runs a brief dealing animation +// before transitioning into Playing. The animation shows a single +// face-down card sprite alternating between A's deck and B's deck +// while a FlipCard sfx clicks on each dealt step. The deck counts +// tick up alongside so it looks like the stacks are actually +// growing. +// +// This state is short (52 * FRAMES_DEAL_STEP / 2 = 104 frames per +// full deal at FRAMES_DEAL_STEP = 4). The jsnes harness captures +// at frame 180 so it will see roughly "halfway through the deal" +// unless we accelerate things. We compromise by dealing one card +// every 2 frames so the full deal lasts ~52 frames, giving +// Playing time to show meaningful content before frame 180. + +state Deal { + on enter { + init_and_shuffle_decks() + // Visually pretend both decks start empty and grow during + // the animation — we animate a `visible_count` counter + // on each side. The underlying deck_*_count starts at + // HALF_DECK after init_and_shuffle_decks; we override the + // on-screen count via deal_next. + deal_next = 0 + deal_timer = 0 + } + + on frame { + global_tick += 1 + deal_timer += 1 + + // ── Dealing tick ───────────────────────────────── + // Deal one card every 2 frames until we've laid down + // all 52. Play a FlipCard sfx on each dealt step for + // the rhythmic click. + if deal_timer >= 2 { + deal_timer = 0 + if deal_next < DECK_SIZE { + deal_next += 1 + play FlipCard + } + } + + // ── Rendering ──────────────────────────────────── + // Draw the "table" furniture: two deck stacks in their + // resting position and the running card counts. The + // deal_next counter controls how many of the 52 have + // "landed", so the first half goes to A and the second + // half to B — matching the actual split_decks() logic. + var dl_dealt_a: u8 = deal_next + var dl_dealt_b: u8 = 0 + if dl_dealt_a > HALF_DECK { + dl_dealt_b = dl_dealt_a - HALF_DECK + dl_dealt_a = HALF_DECK + } + + // Both decks drawn as card backs whenever they have at + // least one card. Before that, skip the draw so the slot + // is empty. + if dl_dealt_a > 0 { + draw_card_back(DECK_A_X, DECK_Y) + } + if dl_dealt_b > 0 { + draw_card_back(DECK_B_X, DECK_Y) + } + + draw_count(COUNT_A_X, COUNT_Y, dl_dealt_a) + draw_count(COUNT_B_X, COUNT_Y, dl_dealt_b) + + // ── Flying card ────────────────────────────────── + // A single face-down card bouncing between the centre + // and each deck. The x position alternates based on the + // low bit of deal_next (even → going to A, odd → B). + if deal_next < DECK_SIZE { + var dl_fly_x: u8 = DECK_A_X + 32 + if (deal_next & 1) != 0 { + dl_fly_x = DECK_B_X - 32 + } + draw_card_back(dl_fly_x, 96) + } + + // ── Transition ─────────────────────────────────── + if deal_next >= DECK_SIZE { + transition Playing + } + } +} diff --git a/examples/war/deck.ne b/examples/war/deck.ne new file mode 100644 index 0000000..4af0891 --- /dev/null +++ b/examples/war/deck.ne @@ -0,0 +1,188 @@ +// war/deck.ne — queue operations on the three u8[52] buffers. +// +// Each of deck_a, deck_b, and pot is a 52-entry circular buffer +// storing packed rank/suit bytes. The front index is the next +// card to draw; the count is the number of cards currently in +// the buffer. Everything wraps mod 52 — since 52 isn't a power +// of two, we use `if idx >= 52 { idx -= 52 }` instead of `%` to +// avoid the expensive software mod routine. +// +// NEScript v0.1 has a flat global symbol table for every `var` +// declaration (function-locals included), so each function's +// locals are prefixed with the function's short name to avoid +// E0501 collisions across the program. Function parameters ARE +// scoped per-function, so we can keep their names short. + +// ── Helpers shared by every deck ────────────────────────── +// +// Wrap a (front + count) sum back into the 0..51 range. Only +// needed inside push_back because `front` and `count` are both +// ≤ 51 by construction, so the sum is at most 102 and one +// subtraction is enough. +// +// Every parameter name in this file is unique across the entire +// program. NEScript v0.1's IR lowering uses a single global +// var_map for parameter names, so two functions both named +// `wrap52(v: u8)` and `another(v: u8)` end up sharing a VarId +// and the codegen routes their parameter reads to whichever +// zero-page slot the LAST function to be lowered claimed. See +// COMPILER_BUGS.md §1b for the gory details. +inline fun wrap52(w52_v: u8) -> u8 { + if w52_v >= DECK_SIZE { + return w52_v - DECK_SIZE + } + return w52_v +} + +// ── deck_a ──────────────────────────────────────────────── + +fun deck_a_empty() -> u8 { + if deck_a_count == 0 { + return 1 + } + return 0 +} + +fun draw_front_a() -> u8 { + var dfa_card: u8 = deck_a[deck_a_front] + deck_a_front = wrap52(deck_a_front + 1) + deck_a_count -= 1 + return dfa_card +} + +fun push_back_a(pba_in: u8) { + // Snapshot `pba_in` into a local before calling wrap52, + // because NEScript v0.1's parameter-passing ABI uses fixed + // zero-page slots — wrap52's first param shares slot $04 + // with our `pba_in` and would silently clobber it. + var pba_card: u8 = pba_in + var pba_slot: u8 = wrap52(deck_a_front + deck_a_count) + deck_a[pba_slot] = pba_card + deck_a_count += 1 +} + +// ── deck_b ──────────────────────────────────────────────── + +fun deck_b_empty() -> u8 { + if deck_b_count == 0 { + return 1 + } + return 0 +} + +fun draw_front_b() -> u8 { + var dfb_card: u8 = deck_b[deck_b_front] + deck_b_front = wrap52(deck_b_front + 1) + deck_b_count -= 1 + return dfb_card +} + +fun push_back_b(pbb_in: u8) { + var pbb_card: u8 = pbb_in + var pbb_slot: u8 = wrap52(deck_b_front + deck_b_count) + deck_b[pbb_slot] = pbb_card + deck_b_count += 1 +} + +// ── pot ─────────────────────────────────────────────────── + +fun push_back_pot(pbp_in: u8) { + pot[pot_count] = pbp_in + pot_count += 1 +} + +fun clear_pot() { + pot_count = 0 +} + +// Transfer every card currently in the pot into deck_a, in FIFO +// order (so the face-up and face-down cards layer naturally). +fun pot_to_a() { + var pta_i: u8 = 0 + while pta_i < pot_count { + push_back_a(pot[pta_i]) + pta_i += 1 + } + pot_count = 0 +} + +fun pot_to_b() { + var ptb_i: u8 = 0 + while ptb_i < pot_count { + push_back_b(pot[ptb_i]) + ptb_i += 1 + } + pot_count = 0 +} + +// ── Init + shuffle ──────────────────────────────────────── +// +// Build a 52-card "master deck" in deck_a's backing array using +// a rank-major loop: for each rank 1..13, each suit 0..3, write +// the packed byte (rank << 4) | suit into deck_a[i]. Then +// bounded-random-swap-shuffle it. Finally, split the first 26 +// into deck_b (copied), leaving the second 26 in deck_a's first +// half, and reset both queues' cursors. +// +// The random-swap shuffle is a bounded alternative to +// Fisher-Yates: it does N swaps between two random indices, where +// each index is rand() & 0x3F (0..63) and the swap is only done +// when both indices are < 52. 200 iterations on a 52-card deck is +// empirically well-mixed and uses only bitwise ops (no multiply, +// no divide, no W0101 warning). + +fun build_master_deck() { + var bmd_r: u8 = 1 + var bmd_i: u8 = 0 + while bmd_r <= RANK_KING { + var bmd_s: u8 = 0 + while bmd_s < 4 { + // Pack rank into the high nibble, suit into the low. + // rank fits in 4 bits (max 13) and suit fits in 2 + // bits, so the shift-and-or is exact. + var bmd_shifted: u8 = bmd_r << 4 + deck_a[bmd_i] = bmd_shifted | bmd_s + bmd_i += 1 + bmd_s += 1 + } + bmd_r += 1 + } +} + +fun shuffle_deck_a() { + var shf_k: u8 = 0 + while shf_k < 200 { + var shf_i: u8 = rand_u8() & 0x3F + var shf_j: u8 = rand_u8() & 0x3F + if shf_i < DECK_SIZE { + if shf_j < DECK_SIZE { + var shf_tmp: u8 = deck_a[shf_i] + deck_a[shf_i] = deck_a[shf_j] + deck_a[shf_j] = shf_tmp + } + } + shf_k += 1 + } +} + +// After shuffle, split deck_a's 52 cards into two halves: the +// first 26 stay in deck_a, the second 26 move into deck_b. +// Reset both queues' front/count cursors in the process. +fun split_decks() { + var spd_i: u8 = 0 + while spd_i < HALF_DECK { + deck_b[spd_i] = deck_a[HALF_DECK + spd_i] + spd_i += 1 + } + deck_a_front = 0 + deck_a_count = HALF_DECK + deck_b_front = 0 + deck_b_count = HALF_DECK + pot_count = 0 +} + +fun init_and_shuffle_decks() { + build_master_deck() + shuffle_deck_a() + split_decks() +} diff --git a/examples/war/play_state.ne b/examples/war/play_state.ne new file mode 100644 index 0000000..9cfe107 --- /dev/null +++ b/examples/war/play_state.ne @@ -0,0 +1,311 @@ +// war/play_state.ne — the Playing state and its inner phase machine. +// +// `phase` cycles through the P_* constants defined in +// war/constants.ne. `phase_timer` counts frames inside the +// current phase and is reset to 0 whenever the phase changes. +// +// All function-local var names are prefixed with the function's +// short name (or with `pf_` for "play frame") so the global +// symbol table stays collision-free. + +// Set the current phase and zero the timer in one shot. Inlined +// so each call site is just two stores. +inline fun set_phase(p: u8) { + phase = p + phase_timer = 0 +} + +// Draw the steady-state table furniture: the two deck card backs +// (if non-empty) and the running card counts. Used as the base +// layer every frame; phase-specific sprites are layered on top. +fun draw_table() { + if deck_a_count > 0 { + draw_card_back(DECK_A_X, DECK_Y) + } + if deck_b_count > 0 { + draw_card_back(DECK_B_X, DECK_Y) + } + draw_count(COUNT_A_X, COUNT_Y, deck_a_count) + draw_count(COUNT_B_X, COUNT_Y, deck_b_count) +} + +// Bury helper for a war: move one card from the deck into the +// pot (face-down). Must be called with a non-empty deck. Two +// near-identical helpers (one per side) keep the locals +// uniquely-named. +fun bury_from_a() { + var bfa_c: u8 = draw_front_a() + push_back_pot(bfa_c) +} +fun bury_from_b() { + var bfb_c: u8 = draw_front_b() + push_back_pot(bfb_c) +} + +// Draw the BIG WAR banner — three 16x16 metasprites at the +// centre of the screen. 12 sprites total, drawn in the centre +// row so they don't conflict with the deck stacks (rows 64-87) +// or the face-up cards (rows 128-151). +// +// All offsets stepped through locals to dodge the `x + N` +// parameter aliasing bug; see draw_word_player. +fun draw_big_war_banner(x: u8, y: u8) { + var bwb_y1: u8 = y + 8 + var bwb_x1: u8 = x + 8 + var bwb_x2: u8 = x + 20 + var bwb_x3: u8 = x + 28 + var bwb_x4: u8 = x + 40 + var bwb_x5: u8 = x + 48 + // BIG W + draw Tileset at: (x, y) frame: TILE_BIG_W_TL + draw Tileset at: (bwb_x1, y) frame: TILE_BIG_W_TR + draw Tileset at: (x, bwb_y1) frame: TILE_BIG_W_BL + draw Tileset at: (bwb_x1, bwb_y1) frame: TILE_BIG_W_BR + // BIG A + draw Tileset at: (bwb_x2, y) frame: TILE_BIG_A_TL + draw Tileset at: (bwb_x3, y) frame: TILE_BIG_A_TR + draw Tileset at: (bwb_x2, bwb_y1) frame: TILE_BIG_A_BL + draw Tileset at: (bwb_x3, bwb_y1) frame: TILE_BIG_A_BR + // BIG R + draw Tileset at: (bwb_x4, y) frame: TILE_BIG_R_TL + draw Tileset at: (bwb_x5, y) frame: TILE_BIG_R_TR + draw Tileset at: (bwb_x4, bwb_y1) frame: TILE_BIG_R_BL + draw Tileset at: (bwb_x5, bwb_y1) frame: TILE_BIG_R_BR +} + +// Begin the A-side draw animation: pull the top card off deck_a, +// stash it as the face-up `card_a`, arm the fly state for the +// deck → play slide, and play the click sfx. +// +// fly_card / fly_face_up are stuffed directly into globals +// instead of being passed to arm_fly, because arm_fly only takes +// 4 params (the v0.1 ABI limit) and silently drops anything past +// the fourth. +fun begin_draw_a() { + if deck_a_count > 0 { + card_a = draw_front_a() + fly_card = card_a + fly_face_up = 1 + // dx_sign 0 = move right (DECK_A_X = 32 → PLAY_A_X = 96). + // dy_sign 0 = move down (DECK_Y = 64 → PLAY_Y = 128). + arm_fly(DECK_A_X, DECK_Y, 0, 0) + play FlipCard + set_phase(P_FLY_A) + } +} + +// Begin the B-side draw. dx_sign 1 = move left +// (DECK_B_X = 208 → PLAY_B_X = 144). dy_sign 0 = move down. +fun begin_draw_b() { + if deck_b_count > 0 { + card_b = draw_front_b() + fly_card = card_b + fly_face_up = 1 + arm_fly(DECK_B_X, DECK_Y, 1, 0) + play FlipCard + set_phase(P_FLY_B) + } +} + +state Playing { + on enter { + set_phase(P_WAIT_A) + card_a = 0 + card_b = 0 + pot_count = 0 + } + + on frame { + global_tick += 1 + phase_timer += 1 + draw_table() + + // ── Phase dispatch ─────────────────────────────── + // The phases share a simple "timer hits target, advance" + // shape. Each arm of the if-chain is self-contained and + // ends either with a set_phase call or a fall-through + // that waits for more input / time. + + if phase == P_WAIT_A { + // Human prompt: hint blink above the deck. + if a_is_cpu == 0 { + if (phase_timer & 32) == 0 { + draw_word_press(8, 200) + } + if button.a or button.start { + begin_draw_a() + } + } else { + // CPU draws after a short delay. + if phase_timer >= CPU_THINK_FRAMES { + begin_draw_a() + } + } + } + + if phase == P_FLY_A { + step_fly_pos() + draw_flying_card(fly_x, fly_y) + if phase_timer >= FRAMES_FLY { + set_phase(P_WAIT_B) + } + } + + if phase == P_WAIT_B { + // A's card is now parked in its play slot. + draw_card_face(PLAY_A_X, PLAY_Y, card_a) + if b_is_cpu == 0 { + if (phase_timer & 32) == 0 { + draw_word_press(208, 200) + } + if p2.button.a or p2.button.start or button.a or button.start { + begin_draw_b() + } + } else { + if phase_timer >= CPU_THINK_FRAMES { + begin_draw_b() + } + } + } + + if phase == P_FLY_B { + // A is in place; B is flying. + draw_card_face(PLAY_A_X, PLAY_Y, card_a) + step_fly_pos() + draw_flying_card(fly_x, fly_y) + if phase_timer >= FRAMES_FLY { + set_phase(P_REVEAL) + } + } + + if phase == P_REVEAL { + draw_card_face(PLAY_A_X, PLAY_Y, card_a) + draw_card_face(PLAY_B_X, PLAY_Y, card_b) + if phase_timer >= FRAMES_REVEAL { + set_phase(P_RESOLVE) + } + } + + if phase == P_RESOLVE { + // Both cards go into the pot regardless of outcome. + push_back_pot(card_a) + push_back_pot(card_b) + var pf_r: u8 = compare_cards(card_a, card_b) + if pf_r == 1 { + play CheerA + set_phase(P_WIN_A) + } + if pf_r == 2 { + play CheerB + set_phase(P_WIN_B) + } + if pf_r == 0 { + // It's a tie — but only enter the war flow if both + // sides actually have cards left to bury. If a + // player ran out of cards on this very tie, the + // OTHER player wins by default and takes the pot. + if deck_a_count == 0 { + pot_to_b() + winner = 1 + transition Victory + } + if deck_b_count == 0 { + pot_to_a() + winner = 0 + transition Victory + } + play WarFlash + set_phase(P_WAR_BANNER) + } + } + + if phase == P_WIN_A { + draw_card_face(PLAY_A_X, PLAY_Y, card_a) + draw_card_face(PLAY_B_X, PLAY_Y, card_b) + if phase_timer >= FRAMES_FLY { + pot_to_a() + set_phase(P_CHECK) + } + } + + if phase == P_WIN_B { + draw_card_face(PLAY_A_X, PLAY_Y, card_a) + draw_card_face(PLAY_B_X, PLAY_Y, card_b) + if phase_timer >= FRAMES_FLY { + pot_to_b() + set_phase(P_CHECK) + } + } + + if phase == P_WAR_BANNER { + draw_card_face(PLAY_A_X, PLAY_Y, card_a) + draw_card_face(PLAY_B_X, PLAY_Y, card_b) + // Flashing big "WAR" banner — only drawn on alternate + // 8-frame windows so the title strobes for emphasis. + if (phase_timer & 8) != 0 { + draw_big_war_banner(96, 80) + } + if phase_timer >= FRAMES_BANNER { + set_phase(P_WAR_BURY) + } + } + + if phase == P_WAR_BURY { + // Bury up to 3 face-down cards from each deck, then + // draw a new face-up pair. We don't animate each + // individual buried card; just play a noise thump + // per buried card and advance the counters. + if phase_timer == 1 { + if deck_a_count > 0 { bury_from_a() } + if deck_b_count > 0 { bury_from_b() } + play ThudDown + } + if phase_timer == 4 { + if deck_a_count > 0 { bury_from_a() } + if deck_b_count > 0 { bury_from_b() } + play ThudDown + } + if phase_timer == 7 { + if deck_a_count > 0 { bury_from_a() } + if deck_b_count > 0 { bury_from_b() } + play ThudDown + } + if phase_timer == 10 { + // Draw new face-ups for the comparison. If either + // side has run out of cards, the OTHER side wins + // and takes the entire pot — we transition straight + // to Victory. + if deck_a_count == 0 { + pot_to_b() + winner = 1 + transition Victory + } + if deck_b_count == 0 { + pot_to_a() + winner = 0 + transition Victory + } + card_a = draw_front_a() + card_b = draw_front_b() + } + if phase_timer >= FRAMES_BURY + 16 { + set_phase(P_REVEAL) + } + } + + if phase == P_CHECK { + if deck_a_count == 0 { + winner = 1 + transition Victory + } + if deck_b_count == 0 { + winner = 0 + transition Victory + } + // No winner yet — start the next round. + card_a = 0 + card_b = 0 + set_phase(P_WAIT_A) + } + } +} diff --git a/examples/war/render.ne b/examples/war/render.ne new file mode 100644 index 0000000..0f3e25b --- /dev/null +++ b/examples/war/render.ne @@ -0,0 +1,226 @@ +// war/render.ne — card, digit, and text rendering helpers. +// +// Every function in this file is a thin wrapper around one or +// more `draw Tileset at: (x, y) frame: N` calls that writes to +// the runtime OAM cursor. Each `draw` takes one sprite slot, so +// a 16×24 card face burns 6 sprite slots, a single-character +// letter burns 1, and so on. The caller is responsible for +// sprite budgeting — see PLAN.md §3. +// +// Function-local var names are prefixed with the function's +// short name to avoid the global symbol-table collisions that +// E0501 would otherwise complain about. + +// ── Card face ────────────────────────────────────────────── +// +// Draws the 6-sprite card face at (x, y) for the packed card +// byte. Layout (each cell is 8×8): +// +// [rank ][small_suit] row 0 +// [pipL ][pipR ] row 1 +// [blankL][blankR ] row 2 +// +// Rank is the condensed glyph at TILE_RANK_BASE + (rank - 1); +// small suit + big-pip halves are indexed directly off their +// base constants. +fun draw_card_face(dcf_in_x: u8, dcf_in_y: u8, dcf_in_card: u8) { + // Snapshot every parameter into a fresh local *before* any + // nested function call. NEScript v0.1 passes parameters via + // fixed zero-page slots ($04, $05, $06), and any inner call + // overwrites those slots with its own parameter values — + // including the inline `card_rank` / `card_suit` calls below, + // which would otherwise leave `x` and `y` corrupted by the + // time the draw lines run. + var dcf_x: u8 = dcf_in_x + var dcf_y: u8 = dcf_in_y + var dcf_card: u8 = dcf_in_card + var dcf_rank: u8 = card_rank(dcf_card) + var dcf_suit: u8 = card_suit(dcf_card) + var dcf_rank_tile: u8 = TILE_RANK_BASE + dcf_rank - 1 + var dcf_small_tile: u8 = TILE_SUIT_SMALL_BASE + dcf_suit + var dcf_pipl_tile: u8 = TILE_PIP_L_BASE + dcf_suit + var dcf_pipr_tile: u8 = TILE_PIP_R_BASE + dcf_suit + var dcf_x1: u8 = dcf_x + 8 + var dcf_y1: u8 = dcf_y + 8 + var dcf_y2: u8 = dcf_y + 16 + // Row 0 — rank corner + small suit + draw Tileset at: (dcf_x, dcf_y) frame: dcf_rank_tile + draw Tileset at: (dcf_x1, dcf_y) frame: dcf_small_tile + // Row 1 — big centre pip (left + right halves) + draw Tileset at: (dcf_x, dcf_y1) frame: dcf_pipl_tile + draw Tileset at: (dcf_x1, dcf_y1) frame: dcf_pipr_tile + // Row 2 — blank bottom so the card body is symmetric + draw Tileset at: (dcf_x, dcf_y2) frame: TILE_FRAME_BLANK_L + draw Tileset at: (dcf_x1, dcf_y2) frame: TILE_FRAME_BLANK_R +} + +// Draw the card-back lattice at (x, y). 6 sprites again. Rows +// 0 and 2 reuse the same top/bottom back tiles; row 1 uses the +// bottom row of the lattice as a filler so the pattern stays +// continuous. +fun draw_card_back(x: u8, y: u8) { + var dcb_x1: u8 = x + 8 + var dcb_y1: u8 = y + 8 + var dcb_y2: u8 = y + 16 + draw Tileset at: (x, y) frame: TILE_BACK_TL + draw Tileset at: (dcb_x1, y) frame: TILE_BACK_TR + draw Tileset at: (x, dcb_y1) frame: TILE_BACK_BL + draw Tileset at: (dcb_x1, dcb_y1) frame: TILE_BACK_BR + draw Tileset at: (x, dcb_y2) frame: TILE_BACK_TL + draw Tileset at: (dcb_x1, dcb_y2) frame: TILE_BACK_TR +} + +// Dispatch between front and back based on the fly_face_up flag +// stamped by the phase handlers. Snapshots params first because +// the inner call clobbers $04/$05. +fun draw_flying_card(x: u8, y: u8) { + var dfc_x: u8 = x + var dfc_y: u8 = y + if fly_face_up == 0 { + draw_card_back(dfc_x, dfc_y) + } else { + draw_card_face(dfc_x, dfc_y, fly_card) + } +} + +// ── Digits + text ───────────────────────────────────────── + +// Draw a single decimal digit 0..9 at (x, y). +fun draw_digit(x: u8, y: u8, d: u8) { + draw Tileset at: (x, y) frame: TILE_DIGIT_BASE + d +} + +// Draw a two-digit decimal count (0..99) at (x, y). Leading zero +// is preserved so the HUD always renders two glyphs wide, which +// keeps the layout stable as the counts change. +fun draw_count(x: u8, y: u8, v: u8) { + var dct_tens: u8 = 0 + var dct_n: u8 = v + // Divide by 10 without the software divide: repeatedly + // subtract 10 until n < 10. For v ≤ 52 this loops at most + // 5 times, cheaper than calling `/` and `%`. + while dct_n >= 10 { + dct_n -= 10 + dct_tens += 1 + } + draw_digit(x, y, dct_tens) + draw_digit(x + 8, y, dct_n) +} + +// Draw a single letter 'A'..'Z' using the sprite font. `ch` is +// the letter index (0 = A, 25 = Z). Deliberately NOT marked +// `inline`: when this was inlined the resulting code put each +// inlined `draw` at double the intended X step (the inliner +// appears to re-evaluate the (x + N) parameter expression in a +// way that compounds across consecutive draws). Keeping it as +// a real function call gives every draw_letter call its own +// argument-evaluation context and the spacing comes out right. +fun draw_letter(x: u8, y: u8, ch: u8) { + draw Tileset at: (x, y) frame: TILE_LETTER_BASE + ch +} + +// Short helper for drawing the word "PLAYER" at (x, y). 6 letters. +// Used by the HUD and the victory banner. +// +// We accumulate the X position in a local instead of passing +// `x + N` as a call argument: the latter pattern miscompiles in +// NEScript v0.1 (consecutive `x + N` arguments to the same +// function appear to alias the parameter slot, leaving the second +// onward call site reading a stale offset). Stepping a local +// avoids the issue entirely. +fun draw_word_player(x: u8, y: u8) { + var dwp_px: u8 = x + draw_letter(dwp_px, y, 15) // P + dwp_px += 8 + draw_letter(dwp_px, y, 11) // L + dwp_px += 8 + draw_letter(dwp_px, y, 0) // A + dwp_px += 8 + draw_letter(dwp_px, y, 24) // Y + dwp_px += 8 + draw_letter(dwp_px, y, 4) // E + dwp_px += 8 + draw_letter(dwp_px, y, 17) // R +} + +// "WINS" — used on the victory screen. +fun draw_word_wins(x: u8, y: u8) { + var dww_px: u8 = x + draw_letter(dww_px, y, 22) // W + dww_px += 8 + draw_letter(dww_px, y, 8) // I + dww_px += 8 + draw_letter(dww_px, y, 13) // N + dww_px += 8 + draw_letter(dww_px, y, 18) // S +} + +// "WAR" — the centred flash during a tie-break. +fun draw_word_war(x: u8, y: u8) { + var dwa_px: u8 = x + draw_letter(dwa_px, y, 22) // W + dwa_px += 8 + draw_letter(dwa_px, y, 0) // A + dwa_px += 8 + draw_letter(dwa_px, y, 17) // R +} + +// "PRESS" — used on the title screen. +fun draw_word_press(x: u8, y: u8) { + var dwr_px: u8 = x + draw_letter(dwr_px, y, 15) // P + dwr_px += 8 + draw_letter(dwr_px, y, 17) // R + dwr_px += 8 + draw_letter(dwr_px, y, 4) // E + dwr_px += 8 + draw_letter(dwr_px, y, 18) // S + dwr_px += 8 + draw_letter(dwr_px, y, 18) // S +} + +// ── Fly animation driver ────────────────────────────────── +// +// We avoid the software multiply (and the W0101 "expensive +// multiply" warning) by stepping the fly position by a fixed +// constant each frame instead of computing `start + dx * t / +// FRAMES`. The constant FLY_STEP is chosen so that +// FRAMES_FLY * FLY_STEP equals the deck-to-play distance on +// both axes: +// +// FRAMES_FLY * FLY_STEP = 64 px (16 * 4) +// +// The screen layout (DECK_*_X, PLAY_*_X, DECK_Y, PLAY_Y) is +// arranged so every animation traverses exactly 64 px on both +// axes, which keeps the per-frame step uniform. +const FLY_STEP: u8 = 4 + +// Step the global (fly_x, fly_y) by FLY_STEP in the directions +// stored in fly_dx_sign / fly_dy_sign. Sign 0 = positive (move +// right / down), 1 = negative (move left / up). Called once per +// frame inside the relevant fly-phase handler. +fun step_fly_pos() { + if fly_dx_sign == 0 { + fly_x += FLY_STEP + } else { + fly_x -= FLY_STEP + } + if fly_dy_sign == 0 { + fly_y += FLY_STEP + } else { + fly_y -= FLY_STEP + } +} + +// Initialise the fly state for a card slide from (sx, sy) using +// the given direction signs. NEScript v0.1 only allocates four +// zero-page slots ($04-$07) for function parameters, so callers +// must stash `fly_card` and `fly_face_up` directly into the +// globals before invoking arm_fly — passing them as the 5th and +// 6th params would silently drop the values on the floor. +fun arm_fly(sx: u8, sy: u8, dxsign: u8, dysign: u8) { + fly_x = sx + fly_y = sy + fly_dx_sign = dxsign + fly_dy_sign = dysign +} diff --git a/examples/war/rng.ne b/examples/war/rng.ne new file mode 100644 index 0000000..5011f33 --- /dev/null +++ b/examples/war/rng.ne @@ -0,0 +1,41 @@ +// war/rng.ne — 8-bit Galois LFSR pseudo-random number generator. +// +// A classic taps = 0xB8 Galois LFSR: on every call, shift the +// state right by one and XOR with the taps polynomial if the old +// low bit was 1. The period is 255 (all non-zero states), which +// is plenty for a card shuffle. +// +// The state lives in the global `rng_state` byte declared in +// war/state.ne. Seeded from a running tick counter on title exit +// (so the jsnes golden harness gets a deterministic shuffle). + +// Advance the LFSR one step and return the new state. Every caller +// treats the return value as the next random byte. +fun rand_u8() -> u8 { + var s: u8 = rng_state + var lsb: u8 = s & 1 + s = s >> 1 + if lsb != 0 { + s = s ^ 0xB8 + } + // Guard against the degenerate all-zero state: if we ever + // roll into zero the LFSR is stuck, so reseed from a fixed + // non-zero constant. In practice this only happens on a + // bad initial seed — we start at 0xA7 so the cycle stays + // healthy. + if s == 0 { + s = 0xA7 + } + rng_state = s + return s +} + +// Seed the LFSR from an arbitrary byte. Zero is remapped to the +// same fallback constant rand_u8() uses so the period stays 255. +fun rng_seed(seed: u8) { + if seed == 0 { + rng_state = 0xA7 + } else { + rng_state = seed + } +} diff --git a/examples/war/state.ne b/examples/war/state.ne new file mode 100644 index 0000000..ea9055c --- /dev/null +++ b/examples/war/state.ne @@ -0,0 +1,82 @@ +// war/state.ne — every mutable variable the game touches. +// +// NEScript allocates top-level `var` declarations in general RAM +// (the analyzer places them starting at $10 / $18 depending on +// whether the program declares background/palette updates). We +// keep everything global so helper functions can read and write +// without having to take parameters and return values — the +// 6502's 8-register ABI makes every extra parameter an extra +// LDA/STA pair. + +// ── Decks + pot ─────────────────────────────────────────── +// Each deck is a circular buffer backed by a 52-byte array. +// `*_front` is the index of the next card to draw; `*_count` is +// the number of cards currently in the buffer. Both fields wrap +// mod 52 — there's a helper for that in war/deck.ne. +var deck_a: u8[52] +var deck_a_front: u8 = 0 +var deck_a_count: u8 = 0 + +var deck_b: u8[52] +var deck_b_front: u8 = 0 +var deck_b_count: u8 = 0 + +// Pot holds every card currently in play (the normal-round draws +// and, during a war, the face-down buries too). Drained into the +// winner's deck after each round resolves. +var pot: u8[52] +var pot_count: u8 = 0 + +// ── The two cards showing face-up this round ───────────── +// These are the packed rank/suit bytes drawn from each deck at +// the start of a round. During a war, the old face-up cards get +// pushed into the pot and new values land in these slots. +var card_a: u8 = 0 +var card_b: u8 = 0 + +// ── Game mode + phase machine ───────────────────────────── +var mode: u8 = 0 // 0/1/2 players +var a_is_cpu: u8 = 1 // bool-ish +var b_is_cpu: u8 = 1 +var phase: u8 = 0 // one of the P_* constants +var phase_timer: u8 = 0 // counts up during each phase + +// ── Animation state ─────────────────────────────────────── +// Shared by every card-fly phase. We step (fly_x, fly_y) by a +// constant FLY_STEP per frame in the directions encoded in +// fly_dx_sign / fly_dy_sign. The screen layout is arranged so +// that FRAMES_FLY * FLY_STEP exactly matches the deck-to-play +// distance on both axes — see render.ne for the math. +var fly_x: u8 = 0 +var fly_y: u8 = 0 +var fly_dx_sign: u8 = 0 // 0 = +FLY_STEP / 1 = -FLY_STEP +var fly_dy_sign: u8 = 0 +var fly_card: u8 = 0 // packed rank/suit to show during the fly +var fly_face_up: u8 = 1 // 0 = show card back, 1 = show face + +// ── Title menu ──────────────────────────────────────────── +var title_cursor: u8 = 1 // menu index (0/1/2) — default "1 PLAYER" +var title_timer: u8 = 0 // auto-advance counter +var title_debounce: u8 = 0 // menu input debounce +var title_blink: u8 = 0 // "PRESS A" blink counter + +// ── Deal state ──────────────────────────────────────────── +var deal_next: u8 = 0 // next card index to deal +var deal_timer: u8 = 0 + +// ── Victory state ───────────────────────────────────────── +var winner: u8 = 0 // 0 = A wins, 1 = B wins +var victory_timer: u8 = 0 + +// ── RNG ────────────────────────────────────────────────── +// 8-bit Galois LFSR state. Seeded from the free-running title +// frame counter at the moment the title screen transitions to +// Deal, so the shuffle is deterministic once the user commits +// to starting a game. The jsnes headless harness always starts +// at frame 45, so the seed is stable there too. +var rng_state: u8 = 0xA7 + +// ── Global free-running frame counter ──────────────────── +// Used by any state that needs a coarse "which frame are we +// on" read without installing its own counter. +var global_tick: u16 = 0 diff --git a/examples/war/title_state.ne b/examples/war/title_state.ne new file mode 100644 index 0000000..02338ff --- /dev/null +++ b/examples/war/title_state.ne @@ -0,0 +1,144 @@ +// war/title_state.ne — the Title state. +// +// Draws a big "WAR" banner, a 3-option menu with a cursor, and a +// blinking "PRESS A" prompt. Autopilot: if the player doesn't +// press anything for TITLE_AUTO_FRAMES frames the menu auto- +// commits to "0 PLAYERS" so the jsnes golden capture reaches +// gameplay by frame 180. +// +// The cursor navigates with D-pad up/down; A or Start confirms. +// Debouncing is done via title_debounce — one key press per +// press rather than per frame held. + +state Title { + on enter { + title_cursor = 1 + title_timer = 0 + title_blink = 0 + title_debounce = 0 + start_music TitleTheme + } + + on frame { + global_tick += 1 + title_timer += 1 + title_blink += 1 + if title_blink >= 60 { + title_blink = 0 + } + if title_debounce > 0 { + title_debounce -= 1 + } + + // ── Big "WAR" title banner ─────────────────────── + // Each letter is a 2x2 block of 16x16 BIG tiles. + // Three letters at 16px wide + 4px gap = 16*3 + 4*2 = 56px; + // centred at x = (256 - 56)/2 = 100. + // y = 32 puts the banner in the top third of the screen. + // BIG W + draw Tileset at: (100, 32) frame: TILE_BIG_W_TL + draw Tileset at: (108, 32) frame: TILE_BIG_W_TR + draw Tileset at: (100, 40) frame: TILE_BIG_W_BL + draw Tileset at: (108, 40) frame: TILE_BIG_W_BR + // BIG A + draw Tileset at: (120, 32) frame: TILE_BIG_A_TL + draw Tileset at: (128, 32) frame: TILE_BIG_A_TR + draw Tileset at: (120, 40) frame: TILE_BIG_A_BL + draw Tileset at: (128, 40) frame: TILE_BIG_A_BR + // BIG R + draw Tileset at: (140, 32) frame: TILE_BIG_R_TL + draw Tileset at: (148, 32) frame: TILE_BIG_R_TR + draw Tileset at: (140, 40) frame: TILE_BIG_R_BL + draw Tileset at: (148, 40) frame: TILE_BIG_R_BR + + // Subtitle "CARD GAME" in 8x8 font under the banner. + // 9 letters incl. the embedded space → 9 sprites per row, + // which over-runs the 8-per-scanline limit. Splitting the + // subtitle across two y rows (offset by 8px) keeps each + // scanline under the limit. + draw_letter(96, 64, 2) // C + draw_letter(104, 64, 0) // A + draw_letter(112, 64, 17) // R + draw_letter(120, 64, 3) // D + draw_letter(136, 64, 6) // G + draw_letter(144, 64, 0) // A + draw_letter(152, 64, 12) // M + draw_letter(160, 64, 4) // E + + // ── Menu options ───────────────────────────────── + // Three lines vertically stacked. Each row is "X PLAYER" + // (no S — plural is implied) so the row never exceeds 7 + // sprites and stays under the per-scanline limit even + // when the cursor sprite shares the same row. + draw_digit(88, 104, 0) + draw_word_player(104, 104) + draw_digit(88, 120, 1) + draw_word_player(104, 120) + draw_digit(88, 136, 2) + draw_word_player(104, 136) + + // Cursor sits to the left of the selected option. + var cursor_y: u8 = 104 + (title_cursor << 4) // 104, 120, 136 + draw Tileset at: (72, cursor_y) frame: TILE_CURSOR + + // ── Blinking "PRESS A" prompt ──────────────────── + if title_blink < 30 { + draw_word_press(96, 184) + draw_letter(144, 184, 0) // A + } + + + // ── Input handling ─────────────────────────────── + if title_debounce == 0 { + if button.up { + if title_cursor > 0 { + title_cursor -= 1 + } + title_debounce = 10 + title_timer = 0 + play FlipCard + } + if button.down { + if title_cursor < 2 { + title_cursor += 1 + } + title_debounce = 10 + title_timer = 0 + play FlipCard + } + if button.a or button.start { + // Commit the selection and seed the RNG from the + // current global tick so each user-visible start + // produces a different shuffle. + mode = title_cursor + rng_seed((global_tick & 0xFF) as u8) + transition Deal + } + } + + // ── Autopilot for the headless harness ─────────── + if title_timer >= TITLE_AUTO_FRAMES { + mode = 0 + rng_seed((global_tick & 0xFF) as u8) + transition Deal + } + } + + on exit { + stop_music + // Translate the menu selection into the two a_is_cpu / + // b_is_cpu flags the play state reads each frame. + if mode == MODE_CPU_VS_CPU { + a_is_cpu = 1 + b_is_cpu = 1 + } + if mode == MODE_HUMAN_VS_CPU { + a_is_cpu = 0 + b_is_cpu = 1 + } + if mode == MODE_HUMAN_VS_HUMAN { + a_is_cpu = 0 + b_is_cpu = 0 + } + } +} diff --git a/examples/war/victory_state.ne b/examples/war/victory_state.ne new file mode 100644 index 0000000..e8cbc6b --- /dev/null +++ b/examples/war/victory_state.ne @@ -0,0 +1,73 @@ +// war/victory_state.ne — the Victory state. +// +// Big "PLAYER A WINS" or "PLAYER B WINS" banner, the builtin +// `fanfare` music, an auto-return to Title after +// VICTORY_LINGER_FRAMES frames, and a manual "press A to skip" +// path. + +state Victory { + on enter { + victory_timer = 0 + start_music fanfare + } + + on frame { + global_tick += 1 + victory_timer += 1 + + // ── Banner ────────────────────────────────────── + // "PLAYER X" on row 1, "WINS" on row 2. Splitting the + // banner across two y rows keeps each scanline under the + // NES's 8-sprite-per-scanline limit (the "PLAYER X WINS" + // single-line version was 11 sprites tall and dropped + // letters past the 8th). + draw_word_player(64, 80) + if winner == 0 { + draw_letter(120, 80, 0) // A + } else { + draw_letter(120, 80, 1) // B + } + draw_word_wins(96, 96) + + // Flourish: draw the winning deck's top card in the + // middle of the screen as a victory showcase. If the + // deck is empty (shouldn't happen — we only arrive + // here because the *loser* is empty) fall back to a + // card back so the layout is stable. + if winner == 0 { + if deck_a_count > 0 { + var vic_top_a: u8 = deck_a[deck_a_front] + draw_card_face(120, 128, vic_top_a) + } else { + draw_card_back(120, 128) + } + } else { + if deck_b_count > 0 { + var vic_top_b: u8 = deck_b[deck_b_front] + draw_card_face(120, 128, vic_top_b) + } else { + draw_card_back(120, 128) + } + } + + // Row of tiny hearts under the banner. + draw Tileset at: (80, 168) frame: TILE_HEART_TINY + draw Tileset at: (96, 168) frame: TILE_HEART_TINY + draw Tileset at: (112, 168) frame: TILE_HEART_TINY + draw Tileset at: (128, 168) frame: TILE_HEART_TINY + draw Tileset at: (144, 168) frame: TILE_HEART_TINY + draw Tileset at: (160, 168) frame: TILE_HEART_TINY + + // ── Input + auto-return ───────────────────────── + if button.a or button.start { + transition Title + } + if victory_timer >= VICTORY_LINGER_FRAMES { + transition Title + } + } + + on exit { + stop_music + } +} diff --git a/tests/emulator/goldens/war.audio.hash b/tests/emulator/goldens/war.audio.hash new file mode 100644 index 0000000..4618f17 --- /dev/null +++ b/tests/emulator/goldens/war.audio.hash @@ -0,0 +1 @@ +37075c32 132084 diff --git a/tests/emulator/goldens/war.png b/tests/emulator/goldens/war.png new file mode 100644 index 0000000000000000000000000000000000000000..9d1cf8280ebb7c00f75ee4eea3a2da49f2697f41 GIT binary patch literal 10005 zcmeHNe^67|8I9c;c589$&RRgB&2DX*+O0;^N*IL9wkq<+#=6^%F7AR0T52)4(X9|C zn>gxBTQRUSYpsgO%K(X5;`E8h@_X5d!z=!H032M%AnA`*S? zZqCZrepWW4@6u}}?loW8d}Hmq3D^Ie`tVoYV=w12D zWgpl7IP==hn@xkOqMd+WZRo}1Gv9wAAk!wH5k!P*GLH`^XSxB+9Uo8(p-6%Z#Sn^w zBtk+lgr*X4451i8q0;gH;?%+Y57dJvM(aEecjx&p2Rbfv1(M%xFBkV$9v8pE{s#A} z6-U#WTS=W9!q1xeDvx8pj!dc6BH#76<$9pQH}@eN7yh ztam^+dM(hQzF(BNtCF-+&;d$Tt?(Il4<7xu?uYE%&z=4Nh8sG@r92PpTF7^o8$bNa>bW!%ZFH3{SQHmnr)msO#jr*te!-Vd`j$$fH59*`8WPN0fm# zt|Y&(++?_tkPUD$wc=8vDxR=mxC!yDr`;vrpUJlx4TtGN2+WwsSq4w=pN{mpyJT?< zoE-am)-QB7N#kmwjjQhv;w{+BE!P5UI$`lbZHtgDcJh&YY4RF+(tS5%aX{=kxi%%Y z+}}0w>D8S>2pEr!lPkfMA^7}U(nJToBczLVuU$eo6=Dq1m*>bOR-#sDx6l-vRVGVa zNzl|slRfk2P+3(`S!F}$UAg6^5D6?we}<6ux3vKBW0$e3myWe%ogsLse^!^}1gr2d4lGR%4UJ>Fy9+!spg(e#xU$qPIN-x;~}@Xq;wXOt_`g187z& z5u7oRB^w^X__~+wx0F6Y<$zJL5zIA&5`mZ*vnHZS)I#`H@e8w-v5hEX7C0%V%x{4@oF(Ld|(5ZRyktSB&iN?hG!o?^C2gw!IS<;}pOpSaK ztbT3_u0V-LkTGVxJrx|N^}(IN;l&k~WO1$}komTsB#u1X1ZY>2mH|*fBYe$NWlHH+ ze+-wy@sBd5Ffc1r4X$0Yl0uZSPZiIvW$no)6^#YbL|YBs-xh(RX^8{v$nNYRARpt%x;IcYWKFWxC$b3ji%Eq>auFF>7I-lXY0L8Q6z9HBN5$8=0YFO{ zN%S5yOeaN97aB!^!D!vi#t#z1y%Fg`d0G)$tJVfLd}TAW>ISX{zO9*@&0O}kM5;BiLdTF>*&r#Nki+X#Q}l2 zZ%M;Ifzi;TpH15dRz651Og4nRbtnL<0E#S5{H`!6jpdJkb5F_HbIWT{3yE4$wb7S_ z7L|@I8eBk)TpU(!XwMT@v`{(8pt^t=K;7Q}-yGybaDit>)}` zc&=x?a}9NeI!Z8-gK*?w?DbVqM9!oclvCW-qYonO_EWN0Q@A;0-)LPuf1aghQvMi8 z??FF$>ctNNM|Dxk1+S@%J?UHTc?C7f6HwN7aMp%7q&03BK;0bA{U}~v>#J&+WsC*% z?I{Z1MEazj{oC;Ytz8_d0+WrHrBGI_UZ%5~jCImiaU&M9-t$Bgy_m@Q#F7NAKjC``fw>LUCXe{oH zv+w`pd|qOU%Kyp82X7iRZPSTFKR!Ews_8@$xt;mZk_4!qnM#Qjfy*FC{L^FOK2#lfBCz2 zq_hMb2A3?*V1#X3huyM@V2uP-ZF^>Vg-iFKO_dVd7@VF_nfEu4|9Z<=rC53T I$D8*454{^{Pyhe` literal 0 HcmV?d00001