1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-21 06:17:18 +00:00

ir/codegen: signed comparison lowering for i8/i16

Closes the §A follow-up gap: ordering compares (`<`, `<=`, `>`, `>=`)
on signed integer types now use the canonical 6502 `CMP / SBC / BVC /
EOR #$80` overflow-correction idiom so the N flag reflects the true
sign of the difference, instead of the previous BCC/BCS-based path
that always treated `$FFxx` as greater than `$00yy`.

The same change also fixes narrow-to-wide widening: assigning a
runtime `i8` expression to an `i16` variable now sign-extends the
high byte via a new `IrOp::SignExtend` op instead of zero-extending
it, so `var w: i16 = some_i8_neg` round-trips negative values.

The lowerer tracks signedness on each IR temp (analogous to the
existing `wide_hi` map) and threads it onto the new `Signedness`
field of `CmpLt`/`CmpGt`/`CmpLtEq`/`CmpGtEq` and their 16-bit
variants. The optimizer's constant-folder uses the same flag to
fold compares correctly under either signedness. Casts to `u8`/`u16`
strip the signed flag so an explicit `as` opt-out stays unsigned.

`examples/signed_compare.ne` exercises both bit widths through the
emulator harness — the four pip sprites at the top of the screen
show three lit (signed-correct) and one dark (would only light if
the compare regressed to unsigned semantics).
This commit is contained in:
Claude 2026-04-19 00:17:34 +00:00
parent 3719b6c0dd
commit 9719dc4111
No known key found for this signature in database
11 changed files with 878 additions and 81 deletions

View file

@ -50,7 +50,8 @@ Open any `.nes` file in an NES emulator ([Mesen](https://www.mesen.ca/), [FCEUX]
| `auto_sprite_flicker.ne` | `game { sprite_flicker: true }` | The `game` attribute equivalent of calling `cycle_sprites` at the top of every `on frame` handler. Same 12-sprite layout as `sprite_flicker_demo.ne`, minus the explicit call — the IR lowerer injects the op automatically when the flag is set, so it's byte-identical to a hand-rolled version without the per-site boilerplate. |
| `fade_demo.ne` | `fade_out(n)`, `fade_in(n)` | Blocking fade helpers that walk brightness 4 → 0 and 0 → 4 with `n` frames per step. The runtime splices `__fade_out` / `__fade_in` plus a callable `__wait_frame_rt` helper when the builtin is used; fade use also forces `__set_palette_brightness` to be linked in since the fade body JSRs into it. |
| `sprite_0_split_demo.ne` | `sprite_0_split(x, y)` | Mid-frame scroll change driven by the PPU's sprite-0 hit flag (`$2002` bit 6), so the effect works on any mapper — NROM, UxROM, MMC1 — not just MMC3 via `on_scanline(N)`. Two-phase busy-wait (wait for clear, then wait for set) guarantees the hit we're responding to came from the current frame. Requires a sprite in OAM slot 0 that overlaps opaque background pixels; this demo uses a full smiley background so every frame's sprite-0 hit fires deterministically. |
| `i16_demo.ne` | `i16` signed 16-bit type | Negative literals fold to wide two's complement (`-10``$FFF6`), so `var vy: i16 = -10` stores the right bytes instead of the zero-extended `$00F6`. Comparisons currently use the unsigned 16-bit compare path (matching existing `i8` behaviour) — fine for positive ranges, wrong for negative compares. The companion `i16_negative_literal_sign_extends_to_wide_store` integration test guards the literal-fold path. |
| `i16_demo.ne` | `i16` signed 16-bit type | Negative literals fold to wide two's complement (`-10``$FFF6`), so `var vy: i16 = -10` stores the right bytes instead of the zero-extended `$00F6`. The companion `i16_negative_literal_sign_extends_to_wide_store` integration test guards the literal-fold path. |
| `signed_compare.ne` | signed `<` / `<=` / `>` / `>=` on `i8` and `i16` | Bounces a marker between X = 32 and X = 224 driven by signed `i16` compares against negative deltas, plus four pip sprites at the top of the screen that gate on directly-negative compares (`i8_neg < 0`, `i16_minus_one < i16_one`, etc.). The signed lowering uses the canonical `CMP / SBC / BVC / EOR #$80` overflow-correction idiom in `gen_cmp_signed_set_n` so the N flag reflects the true sign of the difference. The fourth pip is intentionally dark — it would only light if the lowering fell back to unsigned semantics. The companion integration tests `signed_i16_lt_emits_overflow_corrected_branch` and `signed_i8_lt_emits_overflow_corrected_branch` enforce the asm shape. |
| `sram_demo.ne` | `save { var ... }` | Battery-backed save block. The analyzer allocates `high_score` and `coins` at `$6000+` (cartridge SRAM window) instead of main RAM, and the linker flips iNES header byte-6 bit-1 so emulators (FCEUX, Mesen, Nestopia) load and persist the region from a `.sav` file alongside the ROM. SRAM is uninitialized at first power-on; production games should reserve a magic-byte sentinel and validate it before trusting the rest of the data — the compiler doesn't auto-initialize and emits W0111 if you try. |
| `vram_buffer_demo.ne` | `nt_set`, `nt_attr`, `nt_fill_h` | Minimal VRAM update buffer exercise — three single-tile writes, a 16-tile horizontal fill, and an attribute write firing every frame. Useful as a test case; see `hud_demo.ne` for a realistic usage pattern. |
| `hud_demo.ne` | VRAM buffer driving a classic status bar | A bouncing ball playfield with a HUD across the top: a 5-cell lives indicator that ticks down once per second via `nt_fill_h`, a score counter at the right edge that bumps on every wall hit via `nt_set`, and a one-shot `nt_attr` call at startup that flips the top-left metatile group to a red "UI chrome" palette. Shadow-comparing `score` / `lives` to their `last_*` copies keeps the buffer empty on the ~58-of-60 frames when nothing changed — per-frame cost scales with what actually moved. This is the pattern every nesdoug scoreboard / dialog box / destroyed-metatile animation is built on. |

View file

@ -0,0 +1,75 @@
// Signed-comparison demo — exercises the signed lowering of
// `<` / `>` / `<=` / `>=` on `i8` and `i16` against negative
// values. The pre-fix behaviour (unsigned BCC/BCS branches on
// signed types) gave the wrong answer for any compare that
// crossed zero — `var v: i16 = -1; if v < 0` was always false
// because $FFFF compared greater than $0000 unsigned.
//
// What this program shows on screen at frame 180 (the harness
// snapshot frame):
//
// - Sprite 0 (Marker, X = signed_x_pos) bounces between
// X = 32 and X = 224. The bounce flips when the i16
// position passes the bounds, which only works if the
// signed compare lowers correctly. By frame 180 the
// marker has executed enough bounces to land at a
// position that an unsigned-compare regression would
// not be able to reach.
//
// - Pip 1 (X=64) lights iff `i8_neg < 0` evaluates true.
// A regression to the unsigned path would treat $FF
// as >= $00 and the pip would go dark.
//
// - Pip 2 (X=96) lights iff `(i16_minus_one) < (i16_one)`.
// Same story but on the wide path — the BVC / EOR #$80
// idiom in `gen_cmp16_signed` is what lets it fire.
//
// - Pip 3 (X=128) lights iff `i8_neg <= i8_neg2` where
// i8_neg = -10, i8_neg2 = -1, so the comparison is
// -10 <= -1 (true). Wrong-path lowering would compare
// $F6 <= $FF unsigned, which is also true by accident
// — *but* if we flip to `i8_neg2 <= i8_neg` (-1 <= -10,
// false signed, true unsigned) Pip 4 should be DARK.
// Both pips together let the harness distinguish signed
// from unsigned semantics.
//
// - Pip 4 (X=160) is intentionally driven by the
// opposite-direction compare so a regression to
// unsigned semantics would light it. With the signed
// path it stays dark.
//
// Build: cargo run --release -- build examples/signed_compare.ne
game "Signed Compare" {
mapper: NROM
}
var i8_neg: i8 = -1
var i8_neg2: i8 = -10
var i16_minus_one: i16 = -1
var i16_one: i16 = 1
var signed_x_pos: i16 = 32
var dx: i16 = 1
on frame {
// Bounce the marker between 32 and 224 using i16 signed
// comparisons. The arithmetic lives in i16 land so the
// signed-compare path is the only one that can produce
// the right turnaround.
signed_x_pos += dx
if signed_x_pos >= 224 { dx = -1 }
if signed_x_pos <= 32 { dx = 1 }
// Marker — its X coordinate is the i16 position truncated
// to u8 for the draw call.
var mx: u8 = signed_x_pos as u8
draw Marker at: (mx, 120)
// Pips at the top of the screen, each gated on a signed
// comparison.
if i8_neg < 0 { draw Pip at: (64, 16) }
if i16_minus_one < i16_one { draw Pip at: (96, 16) }
if i8_neg2 <= i8_neg { draw Pip at: (128, 16) }
if i8_neg <= i8_neg2 { draw Pip at: (160, 16) }
}
start Main

BIN
examples/signed_compare.nes Normal file

Binary file not shown.

View file

@ -27,7 +27,9 @@ use std::collections::HashMap;
use crate::analyzer::VarAllocation;
use crate::asm::{AddressingMode as AM, Instruction, Opcode::*};
use crate::assets::{MusicData, SfxData};
use crate::ir::{IrBasicBlock, IrFunction, IrOp, IrProgram, IrTemp, IrTerminator, VarId};
use crate::ir::{
IrBasicBlock, IrFunction, IrOp, IrProgram, IrTemp, IrTerminator, Signedness, VarId,
};
use crate::parser::ast::Channel;
use crate::runtime::{
AUDIO_NOISE_COUNTER, AUDIO_NOISE_PTR_HI, AUDIO_NOISE_PTR_LO, AUDIO_SFX_PITCH_PTR_HI,
@ -1141,22 +1143,30 @@ impl<'a> IrCodeGen<'a> {
(
IrOp::CmpEq(d, a, b)
| IrOp::CmpNe(d, a, b)
| IrOp::CmpLt(d, a, b)
| IrOp::CmpGt(d, a, b)
| IrOp::CmpLtEq(d, a, b)
| IrOp::CmpGtEq(d, a, b),
| IrOp::CmpLt(d, a, b, _)
| IrOp::CmpGt(d, a, b, _)
| IrOp::CmpLtEq(d, a, b, _)
| IrOp::CmpGtEq(d, a, b, _),
IrTerminator::Branch(cond, true_lbl, false_lbl),
) if cond == d && self.use_counts.get(d).copied().unwrap_or(0) == 1 => {
let kind = match op {
IrOp::CmpEq(..) => CmpKind::Eq,
IrOp::CmpNe(..) => CmpKind::Ne,
IrOp::CmpLt(..) => CmpKind::Lt,
IrOp::CmpGt(..) => CmpKind::Gt,
IrOp::CmpLtEq(..) => CmpKind::LtEq,
IrOp::CmpGtEq(..) => CmpKind::GtEq,
let (kind, signed) = match op {
IrOp::CmpEq(..) => (CmpKind::Eq, Signedness::Unsigned),
IrOp::CmpNe(..) => (CmpKind::Ne, Signedness::Unsigned),
IrOp::CmpLt(.., s) => (CmpKind::Lt, *s),
IrOp::CmpGt(.., s) => (CmpKind::Gt, *s),
IrOp::CmpLtEq(.., s) => (CmpKind::LtEq, *s),
IrOp::CmpGtEq(.., s) => (CmpKind::GtEq, *s),
_ => unreachable!(),
};
Some((*d, *a, *b, kind, true_lbl.clone(), false_lbl.clone()))
Some((
*d,
*a,
*b,
kind,
signed,
true_lbl.clone(),
false_lbl.clone(),
))
}
_ => None,
});
@ -1178,13 +1188,13 @@ impl<'a> IrCodeGen<'a> {
self.retire_op_sources(op);
}
if let Some((d, a, b, kind, true_lbl, false_lbl)) = fuse_cmp_branch {
if let Some((d, a, b, kind, signed, true_lbl, false_lbl)) = fuse_cmp_branch {
// Emit the fused compare + branch *first*. Retiring
// a/b before the emit would free their slots while
// the values are still live — `load_temp(a)` would
// then re-allocate `a` to whatever stale slot the
// free list pops next.
self.gen_cmp_branch(a, b, kind, &true_lbl, &false_lbl);
self.gen_cmp_branch(a, b, kind, signed, &true_lbl, &false_lbl);
// Now that the CMP has read both operands, drop their
// use counts the same way `retire_op_sources` would
// for a non-fused Cmp op. The destination temp's
@ -1231,9 +1241,23 @@ impl<'a> IrCodeGen<'a> {
a: IrTemp,
b: IrTemp,
kind: CmpKind,
signed: Signedness,
true_label: &str,
false_label: &str,
) {
// Signed ordering compares lower through a different
// primitive (`gen_cmp_signed_set_n`) that leaves the result
// in the N flag instead of the carry flag, so dispatch out
// before the unsigned `LDA / CMP` lead-in.
if signed == Signedness::Signed
&& matches!(
kind,
CmpKind::Lt | CmpKind::Gt | CmpKind::LtEq | CmpKind::GtEq
)
{
self.gen_cmp_branch_signed(a, b, kind, true_label, false_label);
return;
}
self.load_temp(a);
let b_addr = self.temp_addr(b);
self.emit(CMP, AM::ZeroPage(b_addr));
@ -1294,6 +1318,102 @@ impl<'a> IrCodeGen<'a> {
self.emit(JMP, AM::Label(false_blk));
}
/// Fused signed compare + branch. After
/// [`gen_cmp_signed_set_n`], the N flag holds `1` iff
/// `a < b` under signed semantics; convert that single flag
/// into a branch to either `true_label` or `false_label`
/// depending on the requested predicate. Mirrors the unsigned
/// `gen_cmp_branch` shape (skip-over-`JMP` to keep the
/// destination in `JMP` range) so distance to the target
/// block is unconstrained.
fn gen_cmp_branch_signed(
&mut self,
a: IrTemp,
b: IrTemp,
kind: CmpKind,
true_label: &str,
false_label: &str,
) {
// For `Gt`/`GtEq`, swap operands so the underlying primitive
// still computes "is left < right?" — `a > b` is `b < a`,
// and `a >= b` is `!(a < b)`.
let (lhs, rhs, branch_kind) = match kind {
CmpKind::Lt => (a, b, SignedBranchKind::Lt),
CmpKind::Gt => (b, a, SignedBranchKind::Lt),
CmpKind::GtEq => (a, b, SignedBranchKind::GtEq),
CmpKind::LtEq => (b, a, SignedBranchKind::GtEq),
CmpKind::Eq | CmpKind::Ne => unreachable!("equality is signedness-independent"),
};
self.gen_cmp_signed_set_n(lhs, rhs);
let suffix = self.local_label_suffix();
let skip_label = format!("__ir_cmp_skip_{suffix}");
let true_blk = format!("__ir_blk_{true_label}");
let false_blk = format!("__ir_blk_{false_label}");
match branch_kind {
SignedBranchKind::Lt => {
// N=1 means lhs < rhs; jump to true. Inverted = BPL.
self.emit(BPL, AM::LabelRelative(skip_label.clone()));
self.emit(JMP, AM::Label(true_blk));
self.emit_label(&skip_label);
}
SignedBranchKind::GtEq => {
// N=0 means lhs >= rhs; jump to true. Inverted = BMI.
self.emit(BMI, AM::LabelRelative(skip_label.clone()));
self.emit(JMP, AM::Label(true_blk));
self.emit_label(&skip_label);
}
}
self.emit(JMP, AM::Label(false_blk));
}
/// Subtract `b` from `a` as a one-byte signed compare and leave
/// the **N** flag set iff `a < b` under signed semantics. Uses
/// the standard 6502 idiom: subtract, then if signed overflow
/// occurred (V=1) flip N via `EOR #$80` so the resulting flag
/// reflects the true sign of the difference.
///
/// Trashes A. The C/V flags are left in the SBC's natural state
/// — callers that only care about N (the only consumer today)
/// don't need to touch them.
fn gen_cmp_signed_set_n(&mut self, a: IrTemp, b: IrTemp) {
self.load_temp(a);
self.emit(SEC, AM::Implied);
let b_addr = self.temp_addr(b);
self.emit(SBC, AM::ZeroPage(b_addr));
let suffix = self.local_label_suffix();
let skip_label = format!("__ir_cmp_s_no_v_{suffix}");
// No overflow — N is already correct, skip the flip.
self.emit(BVC, AM::LabelRelative(skip_label.clone()));
// Overflow — flip the N flag by toggling A's bit 7. The
// subsequent BMI/BPL only inspects N, so we don't have to
// re-establish C or V.
self.emit(EOR, AM::Immediate(0x80));
self.emit_label(&skip_label);
}
/// Emit `dest = (src bit 7 == 1) ? $FF : $00`. The 6-instruction
/// branchless form is shorter than the obvious BMI lowering and
/// has deterministic timing — both nice properties for an op
/// that lands inside hot 16-bit-arithmetic prologues.
fn gen_sign_extend(&mut self, dest: IrTemp, src: IrTemp) {
self.load_temp(src);
// Move sign bit into carry.
self.emit(ASL, AM::Accumulator);
// After ASL, carry = original bit 7. Build $FF (negative)
// or $00 (non-negative) without a branch:
// LDA #$00; ADC #$FF; EOR #$FF
// C=1 (was negative) → $00 + $FF + 1 = $00 (carry set) → EOR → $FF
// C=0 (was positive) → $00 + $FF + 0 = $FF → EOR → $00
self.emit(LDA, AM::Immediate(0x00));
self.emit(ADC, AM::Immediate(0xFF));
self.emit(EOR, AM::Immediate(0xFF));
self.store_temp(dest);
}
#[allow(clippy::too_many_lines)]
fn gen_op(&mut self, op: &IrOp) {
match op {
@ -1430,12 +1550,13 @@ impl<'a> IrCodeGen<'a> {
self.emit(EOR, AM::Immediate(0xFF));
self.store_temp(*d);
}
IrOp::CmpEq(d, a, b) => self.gen_cmp(*d, *a, *b, CmpKind::Eq),
IrOp::CmpNe(d, a, b) => self.gen_cmp(*d, *a, *b, CmpKind::Ne),
IrOp::CmpLt(d, a, b) => self.gen_cmp(*d, *a, *b, CmpKind::Lt),
IrOp::CmpGt(d, a, b) => self.gen_cmp(*d, *a, *b, CmpKind::Gt),
IrOp::CmpLtEq(d, a, b) => self.gen_cmp(*d, *a, *b, CmpKind::LtEq),
IrOp::CmpGtEq(d, a, b) => self.gen_cmp(*d, *a, *b, CmpKind::GtEq),
IrOp::CmpEq(d, a, b) => self.gen_cmp(*d, *a, *b, CmpKind::Eq, Signedness::Unsigned),
IrOp::CmpNe(d, a, b) => self.gen_cmp(*d, *a, *b, CmpKind::Ne, Signedness::Unsigned),
IrOp::CmpLt(d, a, b, s) => self.gen_cmp(*d, *a, *b, CmpKind::Lt, *s),
IrOp::CmpGt(d, a, b, s) => self.gen_cmp(*d, *a, *b, CmpKind::Gt, *s),
IrOp::CmpLtEq(d, a, b, s) => self.gen_cmp(*d, *a, *b, CmpKind::LtEq, *s),
IrOp::CmpGtEq(d, a, b, s) => self.gen_cmp(*d, *a, *b, CmpKind::GtEq, *s),
IrOp::SignExtend(d, src) => self.gen_sign_extend(*d, *src),
IrOp::ArrayLoad(dest, var, idx) => {
let base_addr = self.var_addr(*var);
self.load_temp(*idx);
@ -1945,42 +2066,62 @@ impl<'a> IrCodeGen<'a> {
a_hi,
b_lo,
b_hi,
} => self.gen_cmp16(*dest, *a_lo, *a_hi, *b_lo, *b_hi, Cmp16Kind::Eq),
} => self.gen_cmp16(
*dest,
*a_lo,
*a_hi,
*b_lo,
*b_hi,
Cmp16Kind::Eq,
Signedness::Unsigned,
),
IrOp::CmpNe16 {
dest,
a_lo,
a_hi,
b_lo,
b_hi,
} => self.gen_cmp16(*dest, *a_lo, *a_hi, *b_lo, *b_hi, Cmp16Kind::Ne),
} => self.gen_cmp16(
*dest,
*a_lo,
*a_hi,
*b_lo,
*b_hi,
Cmp16Kind::Ne,
Signedness::Unsigned,
),
IrOp::CmpLt16 {
dest,
a_lo,
a_hi,
b_lo,
b_hi,
} => self.gen_cmp16(*dest, *a_lo, *a_hi, *b_lo, *b_hi, Cmp16Kind::Lt),
signed,
} => self.gen_cmp16(*dest, *a_lo, *a_hi, *b_lo, *b_hi, Cmp16Kind::Lt, *signed),
IrOp::CmpGt16 {
dest,
a_lo,
a_hi,
b_lo,
b_hi,
} => self.gen_cmp16(*dest, *a_lo, *a_hi, *b_lo, *b_hi, Cmp16Kind::Gt),
signed,
} => self.gen_cmp16(*dest, *a_lo, *a_hi, *b_lo, *b_hi, Cmp16Kind::Gt, *signed),
IrOp::CmpLtEq16 {
dest,
a_lo,
a_hi,
b_lo,
b_hi,
} => self.gen_cmp16(*dest, *a_lo, *a_hi, *b_lo, *b_hi, Cmp16Kind::LtEq),
signed,
} => self.gen_cmp16(*dest, *a_lo, *a_hi, *b_lo, *b_hi, Cmp16Kind::LtEq, *signed),
IrOp::CmpGtEq16 {
dest,
a_lo,
a_hi,
b_lo,
b_hi,
} => self.gen_cmp16(*dest, *a_lo, *a_hi, *b_lo, *b_hi, Cmp16Kind::GtEq),
signed,
} => self.gen_cmp16(*dest, *a_lo, *a_hi, *b_lo, *b_hi, Cmp16Kind::GtEq, *signed),
IrOp::Rand8(dest) => {
self.emit_rand_marker();
self.emit(JSR, AM::Label("__rand8".into()));
@ -2790,6 +2931,7 @@ impl<'a> IrCodeGen<'a> {
/// a u8 bool (0 or 1) in `dest`. All six comparison kinds are
/// handled uniformly: compare high bytes first, then low bytes
/// only when high bytes are equal.
#[allow(clippy::too_many_arguments)]
fn gen_cmp16(
&mut self,
dest: IrTemp,
@ -2798,7 +2940,20 @@ impl<'a> IrCodeGen<'a> {
b_lo: IrTemp,
b_hi: IrTemp,
kind: Cmp16Kind,
signed: Signedness,
) {
// Signed ordering: dispatch through the signed primitive
// that leaves N=1 when `lhs < rhs`. Eq/Ne are signedness-
// independent, so the unsigned path below handles them.
if signed == Signedness::Signed
&& matches!(
kind,
Cmp16Kind::Lt | Cmp16Kind::Gt | Cmp16Kind::LtEq | Cmp16Kind::GtEq
)
{
self.gen_cmp16_signed(dest, a_lo, a_hi, b_lo, b_hi, kind);
return;
}
let suffix = self.local_label_suffix();
let true_label = format!("__ir_cmp16_t_{suffix}");
let false_label = format!("__ir_cmp16_f_{suffix}");
@ -2888,7 +3043,83 @@ impl<'a> IrCodeGen<'a> {
self.store_temp(dest);
}
fn gen_cmp(&mut self, dest: IrTemp, a: IrTemp, b: IrTemp, kind: CmpKind) {
/// Signed 16-bit ordering compare materializing a 0/1 boolean.
/// Uses the canonical `CMP lo / SBC hi` idiom: the combined
/// effect leaves the **N** flag (after a `BVC`-guarded `EOR
/// #$80` flip on signed overflow) set iff `lhs < rhs` under
/// signed semantics. `Gt` and `LtEq` are derived by swapping
/// operands; `GtEq` is `!Lt`, lowered by inverting the branch.
fn gen_cmp16_signed(
&mut self,
dest: IrTemp,
a_lo: IrTemp,
a_hi: IrTemp,
b_lo: IrTemp,
b_hi: IrTemp,
kind: Cmp16Kind,
) {
let (lhs_lo, lhs_hi, rhs_lo, rhs_hi, want_lt) = match kind {
Cmp16Kind::Lt => (a_lo, a_hi, b_lo, b_hi, true),
Cmp16Kind::Gt => (b_lo, b_hi, a_lo, a_hi, true),
Cmp16Kind::GtEq => (a_lo, a_hi, b_lo, b_hi, false),
Cmp16Kind::LtEq => (b_lo, b_hi, a_lo, a_hi, false),
Cmp16Kind::Eq | Cmp16Kind::Ne => unreachable!("signedness-independent"),
};
self.gen_cmp16_signed_set_n(lhs_lo, lhs_hi, rhs_lo, rhs_hi);
let suffix = self.local_label_suffix();
let true_label = format!("__ir_cmp16_st_{suffix}");
let end_label = format!("__ir_cmp16_se_{suffix}");
// After the primitive: N=1 iff lhs < rhs.
if want_lt {
self.emit(BMI, AM::LabelRelative(true_label.clone()));
} else {
self.emit(BPL, AM::LabelRelative(true_label.clone()));
}
self.emit(LDA, AM::Immediate(0));
self.emit(JMP, AM::Label(end_label.clone()));
self.emit_label(&true_label);
self.emit(LDA, AM::Immediate(1));
self.emit_label(&end_label);
self.store_temp(dest);
}
/// 16-bit signed compare primitive: `CMP a_lo, b_lo / LDA a_hi /
/// SBC b_hi`, then flip N if the SBC overflowed. After this
/// runs, N=1 ↔ `(a_lo:a_hi) < (b_lo:b_hi)` under signed
/// semantics. Trashes A.
fn gen_cmp16_signed_set_n(&mut self, a_lo: IrTemp, a_hi: IrTemp, b_lo: IrTemp, b_hi: IrTemp) {
self.load_temp(a_lo);
let b_lo_addr = self.temp_addr(b_lo);
// CMP performs an unsigned compare and sets carry like SBC
// does, so we can chain it straight into a high-byte SBC
// without an explicit SEC.
self.emit(CMP, AM::ZeroPage(b_lo_addr));
self.load_temp(a_hi);
let b_hi_addr = self.temp_addr(b_hi);
self.emit(SBC, AM::ZeroPage(b_hi_addr));
let suffix = self.local_label_suffix();
let skip = format!("__ir_cmp16_s_no_v_{suffix}");
self.emit(BVC, AM::LabelRelative(skip.clone()));
// Signed overflow occurred — flip the N flag by toggling A
// bit 7. The downstream BMI/BPL only inspects N.
self.emit(EOR, AM::Immediate(0x80));
self.emit_label(&skip);
}
fn gen_cmp(&mut self, dest: IrTemp, a: IrTemp, b: IrTemp, kind: CmpKind, signed: Signedness) {
if signed == Signedness::Signed
&& matches!(
kind,
CmpKind::Lt | CmpKind::Gt | CmpKind::LtEq | CmpKind::GtEq
)
{
self.gen_cmp_signed(dest, a, b, kind);
return;
}
self.load_temp(a);
let b_addr = self.temp_addr(b);
self.emit(CMP, AM::ZeroPage(b_addr));
@ -2923,6 +3154,35 @@ impl<'a> IrCodeGen<'a> {
self.store_temp(dest);
}
/// Signed 8-bit ordering compare. `Gt` swaps to `Lt`, `LtEq`
/// swaps to `GtEq`, then both predicates dispatch through
/// [`gen_cmp_signed_set_n`].
fn gen_cmp_signed(&mut self, dest: IrTemp, a: IrTemp, b: IrTemp, kind: CmpKind) {
let (lhs, rhs, want_lt) = match kind {
CmpKind::Lt => (a, b, true),
CmpKind::Gt => (b, a, true),
CmpKind::GtEq => (a, b, false),
CmpKind::LtEq => (b, a, false),
CmpKind::Eq | CmpKind::Ne => unreachable!("signedness-independent"),
};
self.gen_cmp_signed_set_n(lhs, rhs);
let suffix = self.local_label_suffix();
let true_label = format!("__ir_cmp_st_{suffix}");
let end_label = format!("__ir_cmp_se_{suffix}");
if want_lt {
self.emit(BMI, AM::LabelRelative(true_label.clone()));
} else {
self.emit(BPL, AM::LabelRelative(true_label.clone()));
}
self.emit(LDA, AM::Immediate(0));
self.emit(JMP, AM::Label(end_label.clone()));
self.emit_label(&true_label);
self.emit(LDA, AM::Immediate(1));
self.emit_label(&end_label);
self.store_temp(dest);
}
fn gen_terminator(&mut self, terminator: &IrTerminator) {
match terminator {
IrTerminator::Jump(label) => {
@ -2974,6 +3234,16 @@ enum Cmp16Kind {
GtEq,
}
/// Internal helper for `gen_cmp_branch_signed`: after the operand-
/// swap step, the residual predicate is always one of the two
/// canonical forms — strictly less than, or greater-or-equal — so
/// we only need a two-armed branch lowering.
#[derive(Debug, Clone, Copy)]
enum SignedBranchKind {
Lt,
GtEq,
}
/// Map an IR function name to the analyzer's scope prefix for its
/// locals. The analyzer registers every function-local under
/// `__local__{prefix}__{name}` — state handlers use
@ -3071,6 +3341,7 @@ fn function_is_leaf(func: &IrFunction) -> bool {
| IrOp::ShiftRightVar(..)
| IrOp::Negate(..)
| IrOp::Complement(..)
| IrOp::SignExtend(..)
| IrOp::CmpEq(..)
| IrOp::CmpNe(..)
| IrOp::CmpLt(..)
@ -3286,12 +3557,12 @@ fn op_source_temps(op: &IrOp) -> Vec<IrTemp> {
| IrOp::ShiftRightVar(_, a, b)
| IrOp::CmpEq(_, a, b)
| IrOp::CmpNe(_, a, b)
| IrOp::CmpLt(_, a, b)
| IrOp::CmpGt(_, a, b)
| IrOp::CmpLtEq(_, a, b)
| IrOp::CmpGtEq(_, a, b) => vec![*a, *b],
| IrOp::CmpLt(_, a, b, _)
| IrOp::CmpGt(_, a, b, _)
| IrOp::CmpLtEq(_, a, b, _)
| IrOp::CmpGtEq(_, a, b, _) => vec![*a, *b],
IrOp::ShiftLeft(_, src, _) | IrOp::ShiftRight(_, src, _) => vec![*src],
IrOp::Negate(_, src) | IrOp::Complement(_, src) => vec![*src],
IrOp::Negate(_, src) | IrOp::Complement(_, src) | IrOp::SignExtend(_, src) => vec![*src],
IrOp::ArrayLoad(_, _, idx) => vec![*idx],
IrOp::ArrayStore(_, idx, val) => vec![*idx, *val],
IrOp::Call(_, _, args) => args.clone(),

View file

@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use super::*;
use crate::analyzer::AnalysisResult;
@ -86,6 +86,20 @@ struct LoweringContext {
/// by binary-op, compare, and assignment lowering when they
/// need to decide between `Add`/`Add16`, etc.
wide_hi: HashMap<IrTemp, IrTemp>,
/// Temps whose source value is a signed integer (`i8` / `i16`).
/// Populated by the `LoadVar` / cast / negate / sign-extended-
/// literal paths and propagated through arithmetic in
/// `lower_binop`. Consumed at compare time to pick between
/// `Signedness::Unsigned` and `Signedness::Signed`, and by
/// `widen()` to decide whether the synthesized high byte should
/// be `LoadImm 0` (zero-extension) or [`IrOp::SignExtend`]
/// (sign-extension). Tracking the signedness on the temp itself —
/// rather than re-deriving it from the AST at every consumer —
/// keeps the consumer code matched to the existing `is_wide` /
/// `widen` shape, and means a temp can carry a different
/// signedness than its source variable when the user inserts an
/// explicit `as` cast.
signed_temps: HashSet<IrTemp>,
/// Captured metasprite declarations keyed by name. When a
/// `Statement::Draw` names a metasprite (rather than a flat
/// sprite), the lowering expands it inline into one
@ -191,6 +205,7 @@ impl LoweringContext {
state_names: Vec::new(),
start_state: String::new(),
wide_hi: HashMap::new(),
signed_temps: HashSet::new(),
metasprites: HashMap::new(),
auto_sprite_flicker: false,
}
@ -722,6 +737,7 @@ impl LoweringContext {
// fixed on the War cleanup branch; see `git log` for the
// full reproduction).
self.wide_hi.clear();
self.signed_temps.clear();
self.current_blocks = Vec::new();
self.current_locals = Vec::new();
// Enter the function's local scope so all bare identifier
@ -842,6 +858,7 @@ impl LoweringContext {
// from the previous function and emit
// catastrophically wrong 16-bit IR ops.
self.wide_hi.clear();
self.signed_temps.clear();
self.current_blocks = Vec::new();
self.current_scope_prefix = Some(scope_prefix.to_string());
// Seed `current_locals` with the state's declared locals so any
@ -1600,7 +1617,14 @@ impl LoweringContext {
self.emit(IrOp::LoadVar(var_temp, var_id));
let end_temp = self.lower_expr(end);
let cmp_temp = self.fresh_temp();
self.emit(IrOp::CmpLt(cmp_temp, var_temp, end_temp));
// `for` loops drive a u8 counter today (analyzer enforces),
// so the unsigned compare is always correct.
self.emit(IrOp::CmpLt(
cmp_temp,
var_temp,
end_temp,
Signedness::Unsigned,
));
self.end_block(IrTerminator::Branch(
cmp_temp,
body_label.clone(),
@ -1664,18 +1688,45 @@ impl LoweringContext {
}
/// Return the high-byte temp for a wide value. If `t` is not
/// wide, zero-extend it: allocate a fresh temp, emit `LoadImm 0`,
/// and return the pair. Used before emitting a 16-bit IR op when
/// one operand is narrow and the other is wide.
/// wide, extend it: allocate a fresh temp and emit either
/// `LoadImm 0` (zero-extend, for unsigned narrow values) or
/// [`IrOp::SignExtend`] (for signed narrow values). Used before
/// emitting a 16-bit IR op when one operand is narrow and the
/// other is wide. The signed path is what makes
/// `var s8: i8 = -10; var w: i16 = 0; w = s8` round-trip
/// correctly — without it, the store would land at `$00F6`
/// (=246) instead of `$FFF6` (=-10).
fn widen(&mut self, t: IrTemp) -> (IrTemp, IrTemp) {
if let Some(&hi) = self.wide_hi.get(&t) {
return (t, hi);
}
let hi = self.fresh_temp();
if self.signed_temps.contains(&t) {
self.emit(IrOp::SignExtend(hi, t));
self.signed_temps.insert(hi);
} else {
self.emit(IrOp::LoadImm(hi, 0));
}
(t, hi)
}
/// Mark a temp as carrying a signed value. See `signed_temps` for
/// the consumer model.
fn mark_signed(&mut self, t: IrTemp) {
self.signed_temps.insert(t);
}
/// Combined signedness for a binary op: signed if either operand
/// is signed. Mirrors the way C / Rust promote to the widest
/// signed type when one side is signed.
fn binop_signedness(&self, a: IrTemp, b: IrTemp) -> Signedness {
if self.signed_temps.contains(&a) || self.signed_temps.contains(&b) {
Signedness::Signed
} else {
Signedness::Unsigned
}
}
fn lower_expr(&mut self, expr: &Expr) -> IrTemp {
match expr {
Expr::IntLiteral(v, _) => {
@ -1717,13 +1768,25 @@ impl LoweringContext {
let var_id = self.get_or_create_var(name);
let t = self.fresh_temp();
self.emit(IrOp::LoadVar(t, var_id));
let scoped = self.scoped_key(name);
let var_ty = self
.var_types
.get(&scoped)
.or_else(|| self.var_types.get(name))
.cloned();
// For u16 / i16 variables, also load the high byte
// and register the temp pair as wide so downstream
// ops can emit 16-bit IR when appropriate.
if matches!(self.var_types.get(name), Some(NesType::U16 | NesType::I16)) {
if matches!(var_ty, Some(NesType::U16 | NesType::I16)) {
let hi = self.fresh_temp();
self.emit(IrOp::LoadVarHi(hi, var_id));
self.make_wide(t, hi);
if matches!(var_ty, Some(NesType::I16)) {
self.mark_signed(t);
self.mark_signed(hi);
}
} else if matches!(var_ty, Some(NesType::I8)) {
self.mark_signed(t);
}
t
}
@ -1732,6 +1795,18 @@ impl LoweringContext {
let idx = self.lower_expr(index);
let t = self.fresh_temp();
self.emit(IrOp::ArrayLoad(t, var_id, idx));
let scoped = self.scoped_key(name);
let elem_ty = match self
.var_types
.get(&scoped)
.or_else(|| self.var_types.get(name))
{
Some(NesType::Array(elem, _)) => Some(elem.as_ref().clone()),
_ => None,
};
if matches!(elem_ty, Some(NesType::I8 | NesType::I16)) {
self.mark_signed(t);
}
t
}
Expr::FieldAccess(name, field, _) => {
@ -1745,13 +1820,17 @@ impl LoweringContext {
let var_id = self.get_or_create_var(&full_name);
let t = self.fresh_temp();
self.emit(IrOp::LoadVar(t, var_id));
if matches!(
self.var_types.get(&full_name),
Some(NesType::U16 | NesType::I16)
) {
let var_ty = self.var_types.get(&full_name).cloned();
if matches!(var_ty, Some(NesType::U16 | NesType::I16)) {
let hi = self.fresh_temp();
self.emit(IrOp::LoadVarHi(hi, var_id));
self.make_wide(t, hi);
if matches!(var_ty, Some(NesType::I16)) {
self.mark_signed(t);
self.mark_signed(hi);
}
} else if matches!(var_ty, Some(NesType::I8)) {
self.mark_signed(t);
}
t
}
@ -1772,13 +1851,25 @@ impl LoweringContext {
let hi = self.fresh_temp();
self.emit(IrOp::LoadImm(hi, (negated >> 8) as u8));
self.make_wide(t, hi);
self.mark_signed(t);
self.mark_signed(hi);
} else {
// -0 is still a literal that wants signed
// semantics in any compare it participates in.
self.mark_signed(t);
}
return t;
}
let val = self.lower_expr(inner);
let t = self.fresh_temp();
match op {
UnaryOp::Negate => self.emit(IrOp::Negate(t, val)),
UnaryOp::Negate => {
self.emit(IrOp::Negate(t, val));
// Negation is the canonical signed op — its
// result is always interpreted as a signed
// two's-complement value.
self.mark_signed(t);
}
UnaryOp::Not => {
// Logical not: compare with 0
let zero = self.fresh_temp();
@ -1874,9 +1965,26 @@ impl LoweringContext {
self.emit(IrOp::LoadImm(t, 0));
t
}
Expr::Cast(inner, _, _) => {
// For now, just evaluate the inner expression (truncation/extension is a no-op on 8-bit)
self.lower_expr(inner)
Expr::Cast(inner, target, _) => {
// Lower the inner expression and re-tag the result's
// signedness to match the cast target. Casts to `i8`
// / `i16` mark the temp signed (the user is asserting
// signed interpretation regardless of the source);
// casts to `u8` / `u16` strip the signed flag so
// subsequent compares pick the unsigned path. Width
// changes are still no-ops at IR level — the codegen
// only cares about the low byte for narrowing, and
// widening uses the same `widen()` path as everywhere
// else.
let t = self.lower_expr(inner);
match target {
NesType::I8 | NesType::I16 => self.mark_signed(t),
NesType::U8 | NesType::U16 => {
self.signed_temps.remove(&t);
}
_ => {}
}
t
}
Expr::DebugCall(method, _args, _) => {
// The analyzer already validated the method name and
@ -1947,6 +2055,13 @@ impl LoweringContext {
// variants, which truncate to the low byte. (Multi-byte
// bitwise / multiply could be added later; today they're
// rare enough in NES code to defer.)
// Combined signedness: signed iff either operand is signed.
// For compares the value goes into the IR op's `signed`
// field; for arithmetic we propagate it onto the result temp
// so a chain like `cast_i8 + 1 < limit` keeps signed
// semantics through to the compare.
let sign = self.binop_signedness(l, r);
if wide {
let (a_lo, a_hi) = self.widen(l);
let (b_lo, b_hi) = self.widen(r);
@ -1962,6 +2077,10 @@ impl LoweringContext {
b_hi,
});
self.make_wide(t, d_hi);
if sign == Signedness::Signed {
self.mark_signed(t);
self.mark_signed(d_hi);
}
return t;
}
BinOp::Sub => {
@ -1975,6 +2094,10 @@ impl LoweringContext {
b_hi,
});
self.make_wide(t, d_hi);
if sign == Signedness::Signed {
self.mark_signed(t);
self.mark_signed(d_hi);
}
return t;
}
BinOp::Eq => {
@ -2004,6 +2127,7 @@ impl LoweringContext {
a_hi,
b_lo,
b_hi,
signed: sign,
});
return t;
}
@ -2014,6 +2138,7 @@ impl LoweringContext {
a_hi,
b_lo,
b_hi,
signed: sign,
});
return t;
}
@ -2024,6 +2149,7 @@ impl LoweringContext {
a_hi,
b_lo,
b_hi,
signed: sign,
});
return t;
}
@ -2034,6 +2160,7 @@ impl LoweringContext {
a_hi,
b_lo,
b_hi,
signed: sign,
});
return t;
}
@ -2054,10 +2181,10 @@ impl LoweringContext {
BinOp::BitwiseXor => self.emit(IrOp::Xor(t, l, r)),
BinOp::Eq => self.emit(IrOp::CmpEq(t, l, r)),
BinOp::NotEq => self.emit(IrOp::CmpNe(t, l, r)),
BinOp::Lt => self.emit(IrOp::CmpLt(t, l, r)),
BinOp::Gt => self.emit(IrOp::CmpGt(t, l, r)),
BinOp::LtEq => self.emit(IrOp::CmpLtEq(t, l, r)),
BinOp::GtEq => self.emit(IrOp::CmpGtEq(t, l, r)),
BinOp::Lt => self.emit(IrOp::CmpLt(t, l, r, sign)),
BinOp::Gt => self.emit(IrOp::CmpGt(t, l, r, sign)),
BinOp::LtEq => self.emit(IrOp::CmpLtEq(t, l, r, sign)),
BinOp::GtEq => self.emit(IrOp::CmpGtEq(t, l, r, sign)),
BinOp::ShiftLeft => self.emit(IrOp::ShiftLeftVar(t, l, r)),
BinOp::ShiftRight => self.emit(IrOp::ShiftRightVar(t, l, r)),
BinOp::Div => self.emit(IrOp::Div(t, l, r)),
@ -2065,6 +2192,15 @@ impl LoweringContext {
BinOp::And | BinOp::Or => unreachable!("handled above"),
}
if sign == Signedness::Signed
&& matches!(
op,
BinOp::Add | BinOp::Sub | BinOp::Mul | BinOp::Div | BinOp::Mod
)
{
self.mark_signed(t);
}
t
}

View file

@ -15,6 +15,18 @@ pub struct VarId(pub u32);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct IrTemp(pub u32);
/// Signedness of an arithmetic / comparison operand. The IR is
/// otherwise untyped — temps carry no width or sign information by
/// themselves — so ordering compares (`Lt`, `Gt`, `LtEq`, `GtEq`) and
/// any future sign-aware op carry this enum to tell the codegen which
/// branch lowering to emit. `Eq` / `Ne` and bitwise ops don't need it
/// because their bytewise result is the same either way.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Signedness {
Unsigned,
Signed,
}
impl fmt::Display for IrTemp {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "t{}", self.0)
@ -152,10 +164,21 @@ pub enum IrOp {
// Comparison (sets a boolean temp)
CmpEq(IrTemp, IrTemp, IrTemp),
CmpNe(IrTemp, IrTemp, IrTemp),
CmpLt(IrTemp, IrTemp, IrTemp),
CmpGt(IrTemp, IrTemp, IrTemp),
CmpLtEq(IrTemp, IrTemp, IrTemp),
CmpGtEq(IrTemp, IrTemp, IrTemp),
/// `dest = (a < b)`. `signed` selects between unsigned (`BCC`) and
/// signed (`SBC` + `N XOR V`) branch lowering.
CmpLt(IrTemp, IrTemp, IrTemp, Signedness),
/// `dest = (a > b)`.
CmpGt(IrTemp, IrTemp, IrTemp, Signedness),
/// `dest = (a <= b)`.
CmpLtEq(IrTemp, IrTemp, IrTemp, Signedness),
/// `dest = (a >= b)`.
CmpGtEq(IrTemp, IrTemp, IrTemp, Signedness),
/// Sign-extend an 8-bit value into an 8-bit `dest` byte, producing
/// `$FF` if `src`'s bit 7 is set and `$00` otherwise. Used by the
/// IR lowering when a signed narrow value (i8) is widened to i16
/// — the unsigned path emits `LoadImm 0` for the high byte instead.
SignExtend(IrTemp, IrTemp),
// Array access
ArrayLoad(IrTemp, VarId, IrTemp),
@ -247,39 +270,43 @@ pub enum IrOp {
b_lo: IrTemp,
b_hi: IrTemp,
},
/// 16-bit unsigned less-than. `dest = (a < b) ? 1 : 0`.
/// Codegen compares high bytes first; falls through to compare
/// low bytes only when the high bytes are equal.
/// 16-bit less-than. `dest = (a < b) ? 1 : 0`. `signed` selects
/// between unsigned (high-byte `BCC` + low-byte fall-through) and
/// signed (`CMP lo / SBC hi` then `N XOR V`) branch lowering.
CmpLt16 {
dest: IrTemp,
a_lo: IrTemp,
a_hi: IrTemp,
b_lo: IrTemp,
b_hi: IrTemp,
signed: Signedness,
},
/// 16-bit unsigned greater-than.
/// 16-bit greater-than.
CmpGt16 {
dest: IrTemp,
a_lo: IrTemp,
a_hi: IrTemp,
b_lo: IrTemp,
b_hi: IrTemp,
signed: Signedness,
},
/// 16-bit unsigned less-or-equal.
/// 16-bit less-or-equal.
CmpLtEq16 {
dest: IrTemp,
a_lo: IrTemp,
a_hi: IrTemp,
b_lo: IrTemp,
b_hi: IrTemp,
signed: Signedness,
},
/// 16-bit unsigned greater-or-equal.
/// 16-bit greater-or-equal.
CmpGtEq16 {
dest: IrTemp,
a_lo: IrTemp,
a_hi: IrTemp,
b_lo: IrTemp,
b_hi: IrTemp,
signed: Signedness,
},
/// `set_palette Name` — queues a palette update for the next

View file

@ -1107,3 +1107,164 @@ fn inline_fun_nested_inlines_substitute_correctly() {
.collect::<Vec<_>>()
);
}
#[test]
fn cmp_on_i16_var_emits_signed_kind() {
// Two i16 vars compared via `<` should lower to a CmpLt16 op
// tagged `Signedness::Signed` so the codegen picks the
// signed-branch lowering. This is the regression guard for
// the §A-follow-up gap: before signedness tracking landed, a
// negative i16 always compared greater than zero because the
// BCC-based unsigned path saw $FFxx > $00yy.
let ir = lower_ok(
r#"
game "Test" { mapper: NROM }
var a: i16 = -1
var b: i16 = 1
var hit: u8 = 0
on frame { if a < b { hit = 1 } }
start Main
"#,
);
let frame_fn = ir
.functions
.iter()
.find(|f| f.name.contains("frame"))
.expect("frame handler should exist");
let cmp = frame_fn
.blocks
.iter()
.flat_map(|b| &b.ops)
.find_map(|op| match op {
IrOp::CmpLt16 { signed, .. } => Some(*signed),
_ => None,
})
.expect("expected a CmpLt16 op");
assert_eq!(cmp, Signedness::Signed);
}
#[test]
fn cmp_on_u16_var_stays_unsigned() {
let ir = lower_ok(
r#"
game "Test" { mapper: NROM }
var a: u16 = 0
var b: u16 = 1
var hit: u8 = 0
on frame { if a < b { hit = 1 } }
start Main
"#,
);
let frame_fn = ir
.functions
.iter()
.find(|f| f.name.contains("frame"))
.expect("frame handler should exist");
let cmp = frame_fn
.blocks
.iter()
.flat_map(|b| &b.ops)
.find_map(|op| match op {
IrOp::CmpLt16 { signed, .. } => Some(*signed),
_ => None,
})
.expect("expected a CmpLt16 op");
assert_eq!(cmp, Signedness::Unsigned);
}
#[test]
fn cmp_on_i8_var_emits_signed_kind() {
let ir = lower_ok(
r#"
game "Test" { mapper: NROM }
var a: i8 = -1
var b: i8 = 1
var hit: u8 = 0
on frame { if a < b { hit = 1 } }
start Main
"#,
);
let frame_fn = ir
.functions
.iter()
.find(|f| f.name.contains("frame"))
.expect("frame handler should exist");
let cmp = frame_fn
.blocks
.iter()
.flat_map(|b| &b.ops)
.find_map(|op| match op {
IrOp::CmpLt(_, _, _, signed) => Some(*signed),
_ => None,
})
.expect("expected a CmpLt op");
assert_eq!(cmp, Signedness::Signed);
}
#[test]
fn i8_widened_to_i16_emits_sign_extend() {
// Loading an i8 as the rhs of an i16 add should sign-extend
// the high byte rather than zero-extending it. The IR shape
// we look for is a `SignExtend` op feeding the high-byte
// input to the resulting `Add16`.
let ir = lower_ok(
r#"
game "Test" { mapper: NROM }
var w: i16 = 0
var n: i8 = -10
on frame { w = w + n }
start Main
"#,
);
let frame_fn = ir
.functions
.iter()
.find(|f| f.name.contains("frame"))
.expect("frame handler should exist");
let has_sx = frame_fn
.blocks
.iter()
.flat_map(|b| &b.ops)
.any(|op| matches!(op, IrOp::SignExtend(_, _)));
assert!(
has_sx,
"i8 → i16 widen should emit SignExtend; ops: {:?}",
frame_fn
.blocks
.iter()
.flat_map(|b| &b.ops)
.collect::<Vec<_>>()
);
}
#[test]
fn cast_to_u16_strips_signed_flag() {
// An explicit `as u16` cast on an `i16` value should make the
// subsequent compare lower as unsigned — the user has opted
// out of signed semantics.
let ir = lower_ok(
r#"
game "Test" { mapper: NROM }
var a: i16 = -1
var b: u16 = 1
var hit: u8 = 0
on frame { if (a as u16) < b { hit = 1 } }
start Main
"#,
);
let frame_fn = ir
.functions
.iter()
.find(|f| f.name.contains("frame"))
.expect("frame handler should exist");
let cmp = frame_fn
.blocks
.iter()
.flat_map(|b| &b.ops)
.find_map(|op| match op {
IrOp::CmpLt16 { signed, .. } => Some(*signed),
_ => None,
})
.expect("expected a CmpLt16 op");
assert_eq!(cmp, Signedness::Unsigned);
}

View file

@ -3,7 +3,23 @@ mod tests;
use std::collections::{HashMap, HashSet};
use crate::ir::{IrBasicBlock, IrFunction, IrOp, IrProgram, IrTemp, IrTerminator, VarId};
use crate::ir::{
IrBasicBlock, IrFunction, IrOp, IrProgram, IrTemp, IrTerminator, Signedness, VarId,
};
/// Compare two byte-valued constants under the requested signedness.
/// Used by the constant-folding paths for the ordering comparisons —
/// when both operands have collapsed to compile-time constants, we
/// can pre-compute the boolean and replace the IR op with a
/// `LoadImm`. The signed branch reinterprets the byte through `i8`
/// so that e.g. `LoadImm 0xFF < LoadImm 0x01` folds to `1` under
/// signed semantics (-1 < 1) but `0` under unsigned (255 > 1).
fn cmp_const_signed(a: u8, b: u8, signed: Signedness) -> std::cmp::Ordering {
match signed {
Signedness::Signed => a.cast_signed().cmp(&b.cast_signed()),
Signedness::Unsigned => a.cmp(&b),
}
}
/// Run all optimization passes on the IR program.
pub fn optimize(program: &mut IrProgram) {
@ -291,30 +307,34 @@ fn const_fold_block(block: &mut IrBasicBlock, func_used: &HashSet<IrTemp>) {
constants.insert(dest, result);
}
}
IrOp::CmpLt(dest, a, b) => {
IrOp::CmpLt(dest, a, b, signed) => {
if let (Some(&va), Some(&vb)) = (constants.get(&a), constants.get(&b)) {
let result = u8::from(va < vb);
let result =
u8::from(cmp_const_signed(va, vb, signed) == std::cmp::Ordering::Less);
*op = IrOp::LoadImm(dest, result);
constants.insert(dest, result);
}
}
IrOp::CmpGt(dest, a, b) => {
IrOp::CmpGt(dest, a, b, signed) => {
if let (Some(&va), Some(&vb)) = (constants.get(&a), constants.get(&b)) {
let result = u8::from(va > vb);
let result =
u8::from(cmp_const_signed(va, vb, signed) == std::cmp::Ordering::Greater);
*op = IrOp::LoadImm(dest, result);
constants.insert(dest, result);
}
}
IrOp::CmpLtEq(dest, a, b) => {
IrOp::CmpLtEq(dest, a, b, signed) => {
if let (Some(&va), Some(&vb)) = (constants.get(&a), constants.get(&b)) {
let result = u8::from(va <= vb);
let result =
u8::from(cmp_const_signed(va, vb, signed) != std::cmp::Ordering::Greater);
*op = IrOp::LoadImm(dest, result);
constants.insert(dest, result);
}
}
IrOp::CmpGtEq(dest, a, b) => {
IrOp::CmpGtEq(dest, a, b, signed) => {
if let (Some(&va), Some(&vb)) = (constants.get(&a), constants.get(&b)) {
let result = u8::from(va >= vb);
let result =
u8::from(cmp_const_signed(va, vb, signed) != std::cmp::Ordering::Less);
*op = IrOp::LoadImm(dest, result);
constants.insert(dest, result);
}
@ -461,17 +481,17 @@ fn collect_source_temps(op: &IrOp, used: &mut HashSet<IrTemp>) {
| IrOp::ShiftRightVar(_, a, b)
| IrOp::CmpEq(_, a, b)
| IrOp::CmpNe(_, a, b)
| IrOp::CmpLt(_, a, b)
| IrOp::CmpGt(_, a, b)
| IrOp::CmpLtEq(_, a, b)
| IrOp::CmpGtEq(_, a, b) => {
| IrOp::CmpLt(_, a, b, _)
| IrOp::CmpGt(_, a, b, _)
| IrOp::CmpLtEq(_, a, b, _)
| IrOp::CmpGtEq(_, a, b, _) => {
used.insert(*a);
used.insert(*b);
}
IrOp::ShiftLeft(_, src, _) | IrOp::ShiftRight(_, src, _) => {
used.insert(*src);
}
IrOp::Negate(_, src) | IrOp::Complement(_, src) => {
IrOp::Negate(_, src) | IrOp::Complement(_, src) | IrOp::SignExtend(_, src) => {
used.insert(*src);
}
IrOp::ArrayLoad(_, _, idx) => {
@ -640,12 +660,13 @@ fn op_dest(op: &IrOp) -> Option<IrTemp> {
| IrOp::ShiftRightVar(d, _, _)
| IrOp::Negate(d, _)
| IrOp::Complement(d, _)
| IrOp::SignExtend(d, _)
| IrOp::CmpEq(d, _, _)
| IrOp::CmpNe(d, _, _)
| IrOp::CmpLt(d, _, _)
| IrOp::CmpGt(d, _, _)
| IrOp::CmpLtEq(d, _, _)
| IrOp::CmpGtEq(d, _, _)
| IrOp::CmpLt(d, _, _, _)
| IrOp::CmpGt(d, _, _, _)
| IrOp::CmpLtEq(d, _, _, _)
| IrOp::CmpGtEq(d, _, _, _)
| IrOp::ArrayLoad(d, _, _) => Some(*d),
IrOp::Call(dest, _, _) => *dest,
IrOp::ReadInput(d, _) => Some(*d),

View file

@ -0,0 +1 @@
a82b6ff5 132084

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -3850,6 +3850,110 @@ fn i16_compiles_with_arithmetic_and_compare() {
assert_eq!(info.mapper, 0);
}
#[test]
fn signed_i16_lt_emits_overflow_corrected_branch() {
// `i16` ordering compare must emit the `BVC / EOR #$80`
// overflow-correction idiom that flips the N flag when SBC
// signals signed overflow. Without it, comparing a negative
// i16 against a positive one falls back to an unsigned BCC
// path that always treats $FFxx as greater than $00yy. This
// is the behavioural piece of the §A-follow-up: any i16 `<`
// must lower to a code sequence that can correctly answer
// "is -1 < 1?".
let source = r#"
game "SignedCmp" { mapper: NROM }
var a: i16 = -1
var b: i16 = 1
var hit: u8 = 0
on frame { if a < b { hit = 1 } }
start Main
"#;
let rom = compile(source);
// Look for the overflow-correction byte triple `BVC + EOR
// #$80`. EOR #$80 = `49 80`, and BVC has opcode `50` with a
// single-byte signed offset; the offset value is layout-
// dependent so we just check for the `50 ?? 49 80` shape
// anywhere in PRG.
let prg = &rom[16..16 + 16384];
let has_idiom = prg
.windows(4)
.any(|w| w[0] == 0x50 && w[2] == 0x49 && w[3] == 0x80);
assert!(
has_idiom,
"expected `BVC ?? / EOR #$80` overflow-correction idiom in PRG ROM"
);
}
#[test]
fn signed_i8_lt_emits_overflow_corrected_branch() {
// Same check as above, but for the 8-bit signed compare.
let source = r#"
game "SignedCmp8" { mapper: NROM }
var a: i8 = -1
var b: i8 = 1
var hit: u8 = 0
on frame { if a < b { hit = 1 } }
start Main
"#;
let rom = compile(source);
let prg = &rom[16..16 + 16384];
let has_idiom = prg
.windows(4)
.any(|w| w[0] == 0x50 && w[2] == 0x49 && w[3] == 0x80);
assert!(
has_idiom,
"expected `BVC ?? / EOR #$80` overflow-correction idiom in PRG ROM"
);
}
#[test]
fn unsigned_u16_lt_does_not_emit_signed_idiom() {
// u16 `<` should keep using the cheaper BCC-based unsigned
// path. If the lowering accidentally promoted everything to
// the signed path we'd waste two bytes per compare; the
// overflow-correction idiom must NOT appear.
let source = r#"
game "UnsignedCmp" { mapper: NROM }
var a: u16 = 0
var b: u16 = 1
var hit: u8 = 0
on frame { if a < b { hit = 1 } }
start Main
"#;
let rom = compile(source);
let prg = &rom[16..16 + 16384];
let has_idiom = prg
.windows(4)
.any(|w| w[0] == 0x50 && w[2] == 0x49 && w[3] == 0x80);
assert!(
!has_idiom,
"u16 compare should stay unsigned; found `BVC / EOR #$80` idiom"
);
}
#[test]
fn cmp_signed_against_zero_is_negative_check() {
// Negative analyzer test isn't applicable here (the analyzer
// already accepts negative literals against i8/i16), but we
// can still guard against the silent-drop shape: an i8
// compare against zero should at minimum emit a CMP and a
// signed-overflow guard. This is a smoke-level sibling of
// the existing i16-arithmetic integration test.
let source = r#"
game "SignedZero" { mapper: NROM }
var a: i8 = -5
var hit: u8 = 0
on frame { if a < 0 { hit = 1 } }
start Main
"#;
let rom = compile(source);
let info = rom::validate_ines(&rom).expect("should be valid iNES");
assert_eq!(info.mapper, 0);
let prg = &rom[16..16 + 16384];
let has_sbc = prg.iter().any(|&b| b == 0xE9 || b == 0xE5 || b == 0xED);
assert!(has_sbc, "signed compare should emit at least one SBC");
}
#[test]
fn fade_out_emits_jsr_and_forces_palette_bright() {
// `fade_out(n)` should emit a JSR to `__fade_out` and also