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:
parent
3719b6c0dd
commit
9719dc4111
11 changed files with 878 additions and 81 deletions
|
|
@ -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. |
|
||||
|
|
|
|||
75
examples/signed_compare.ne
Normal file
75
examples/signed_compare.ne
Normal 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
BIN
examples/signed_compare.nes
Normal file
Binary file not shown.
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
161
src/ir/tests.rs
161
src/ir/tests.rs
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
1
tests/emulator/goldens/signed_compare.audio.hash
Normal file
1
tests/emulator/goldens/signed_compare.audio.hash
Normal file
|
|
@ -0,0 +1 @@
|
|||
a82b6ff5 132084
|
||||
BIN
tests/emulator/goldens/signed_compare.png
Normal file
BIN
tests/emulator/goldens/signed_compare.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue