pixel8 0.2.0

Pixel8 fantasy console SDK: write carts in Rust, compile to wasm32-unknown-unknown
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
//! Coherent sub-pixel motion.
//!
//! Pixel8 floors every position to a pixel at draw time, and a cart's `x` and
//! `y` are independent variables. When something moves diagonally at less than
//! one pixel per frame, `floor(x)` and `floor(y)` tick on different frames
//! unless their fractional phases happen to line up — so the steps alternate
//! "x only" then "y only" and the sprite *zigzags* along the diagonal instead
//! of climbing a clean staircase. This is pure integer-grid geometry (it is not
//! a floating-point precision problem, and PICO-8 has it too); the only diagonal
//! the four-button d-pad can produce — holding two buttons for a 45° heading —
//! hits it whenever the two axes start on different sub-pixel fractions.
//!
//! [`Body`] fixes it for carts that opt in. It owns the trajectory (which the
//! immediate-mode renderer can't see) and emits a *phase-coherent* render
//! position: the faster axis steps freely, and a slower-axis step is held back
//! to land on the same frame as a faster-axis step, merging the two into a
//! single diagonal move. The result is a regular staircase. Game logic keeps
//! reading the exact sub-pixel position; only the drawn pixel is made coherent.
//!
//! ```no_run
//! use pixel8::*;
//!
//! struct Mob { body: Body }
//!
//! impl Game for Mob {
//!     fn update(&mut self, ctx: &mut Context) {
//!         // Body just needs a per-frame movement; compute it however the game
//!         // likes — input, physics, a chase. Here it's the held d-pad at a
//!         // sub-pixel speed (the case that would otherwise zigzag).
//!         let speed = 0.6;
//!         let held = ctx.buttons_down();
//!         let mut dx = 0.0;
//!         let mut dy = 0.0;
//!         if held.contains(Button::Left) { dx -= speed; }
//!         if held.contains(Button::Right) { dx += speed; }
//!         if held.contains(Button::Up) { dy -= speed; }
//!         if held.contains(Button::Down) { dy += speed; }
//!         self.body.move_by(dx, dy);
//!     }
//!
//!     fn draw(&self, gfx: &mut Graphics) {
//!         gfx.clear(Color::BLACK);
//!         // draw_x/draw_y are the coherent pixel; collision uses x()/y().
//!         gfx.sprite(SpriteId(1), self.body.draw_x(), self.body.draw_y());
//!     }
//! }
//! ```

/// `|v|`, branch form. `f32::abs` is not guaranteed outside `std`, and the SDK
/// builds `no_std`; this needs no math library.
fn fabs(v: f32) -> f32 {
    if v < 0.0 {
        -v
    } else {
        v
    }
}

/// `floor(v)` as an `i16`, matching what the console does at draw time, without
/// the `std`-only `f32::floor`. Positions on a 128x128 screen are tiny, so the
/// saturating float-to-int cast never bites in practice, but a stray large
/// delta (a teleport, a bad chase target) can still hand this an out-of-range
/// `v`; the floor must saturate to `i16::MIN` rather than wrap past it.
#[inline(always)]
pub(crate) fn floor_i16(v: f32) -> i16 {
    let t = v as i16;
    if (t as f32) > v {
        // `t` is already `i16::MIN` for any `v <= i16::MIN as f32`, so a plain
        // `t - 1` would underflow; saturate there instead of stepping further
        // down than the type can hold.
        t.saturating_sub(1)
    } else {
        t
    }
}

/// A moving body that renders a clean pixel staircase instead of a zigzag.
///
/// Hold the full-precision position for game logic, advance it each frame with
/// [`move_by`](Body::move_by), and draw at [`draw_x`](Body::draw_x) /
/// [`draw_y`](Body::draw_y).
///
/// The drawn pixel never differs from `floor(`[`x`](Body::x)`)` /
/// `floor(`[`y`](Body::y)`)` by more than one pixel, and that difference does
/// not accumulate — so collision against the exact position stays honest while
/// the sprite stops shimmering.
#[derive(Debug, Clone, Copy)]
pub struct Body {
    /// Full-precision position — the truth for game logic and collision.
    x: f32,
    y: f32,
    /// Coherent render position — what to draw, floored and phase-aligned.
    rx: i16,
    ry: i16,
}

impl Body {
    /// A body at a starting position. The drawn pixel starts floored, as usual.
    pub fn new(x: f32, y: f32) -> Self {
        Self {
            x,
            y,
            rx: floor_i16(x),
            ry: floor_i16(y),
        }
    }

    /// Advance one frame by a per-frame delta (i.e. velocity in px/frame).
    ///
    /// The cart owns its own acceleration, friction and input handling; this
    /// just takes the resulting movement for the frame and updates both the
    /// exact position and the coherent render pixel.
    pub fn move_by(&mut self, dx: f32, dy: f32) {
        self.x += dx;
        self.y += dy;
        let fx = floor_i16(self.x);
        let fy = floor_i16(self.y);

        // Whole-pixel-or-faster motion on either axis can't produce the
        // sub-pixel phase zigzag (that axis steps every frame), and we want the
        // drawn pixel exact there, so snap straight to the floored position.
        let (ax, ay) = (fabs(dx), fabs(dy));
        if ax >= 1.0 || ay >= 1.0 {
            self.rx = fx;
            self.ry = fy;
            return;
        }

        // Sub-pixel motion. The faster ("major") axis renders at floor(true)
        // exactly. A step on the slower ("minor") axis is deferred until the
        // frame the major axis also steps, so they land together as one
        // diagonal move rather than as a lone horizontal then a lone vertical —
        // the alternation that reads as a zigzag. The `>= 2` guard forces the
        // minor axis to catch up if it ever lags two pixels, bounding the
        // render position to within one pixel of the true position forever.
        if ax >= ay {
            let major_stepped = fx != self.rx;
            self.rx = fx;
            let lag = fy - self.ry;
            if lag != 0 && (major_stepped || lag.abs() >= 2) {
                self.ry += lag.signum();
            }
        } else {
            let major_stepped = fy != self.ry;
            self.ry = fy;
            let lag = fx - self.rx;
            if lag != 0 && (major_stepped || lag.abs() >= 2) {
                self.rx += lag.signum();
            }
        }
    }

    /// The exact sub-pixel x — use this for game logic and collision.
    pub fn x(&self) -> f32 {
        self.x
    }

    /// The exact sub-pixel y — use this for game logic and collision.
    pub fn y(&self) -> f32 {
        self.y
    }

    /// The exact sub-pixel position `(x, y)`.
    pub fn pos(&self) -> (f32, f32) {
        (self.x, self.y)
    }

    /// The coherent x pixel to draw at.
    pub fn draw_x(&self) -> i16 {
        self.rx
    }

    /// The coherent y pixel to draw at.
    pub fn draw_y(&self) -> i16 {
        self.ry
    }

    /// The coherent render position `(draw_x, draw_y)`.
    pub fn draw_pos(&self) -> (i16, i16) {
        (self.rx, self.ry)
    }

    /// Teleport to a position, re-snapping the render pixel (no coherent step —
    /// this is a jump, not motion).
    pub fn set_pos(&mut self, x: f32, y: f32) {
        self.x = x;
        self.y = y;
        self.rx = floor_i16(x);
        self.ry = floor_i16(y);
    }

    /// The body's whole state — exact position and coherent pixel — for the physics step's
    /// crossing of the ABI, where the console must continue a body exactly as the cart left it.
    /// Not a cart's business.
    #[doc(hidden)]
    pub fn wire(&self) -> (f32, f32, i16, i16) {
        (self.x, self.y, self.rx, self.ry)
    }

    /// A body continued from [`wire`](Self::wire) state, coherent pixel and all. Not a cart's
    /// business.
    #[doc(hidden)]
    pub fn from_wire((x, y, rx, ry): (f32, f32, i16, i16)) -> Self {
        Self { x, y, rx, ry }
    }

    /// This body set to [`wire`](Self::wire) state, coherent pixel and all — a continuation, not
    /// the jump [`set_pos`](Self::set_pos) is. Not a cart's business.
    #[doc(hidden)]
    pub fn set_wire(&mut self, state: (f32, f32, i16, i16)) {
        *self = Self::from_wire(state);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Per-frame step classification of a render path, like the repro script:
    /// `D` both axes, `H` x only, `V` y only, `.` neither. A lone `V` wedged
    /// between `H`s (or vice versa) is the zigzag.
    fn classify(path: &[(i16, i16)]) -> String {
        path.windows(2)
            .map(|w| {
                let (dx, dy) = (w[1].0 - w[0].0, w[1].1 - w[0].1);
                match (dx != 0, dy != 0) {
                    (true, true) => 'D',
                    (true, false) => 'H',
                    (false, true) => 'V',
                    (false, false) => '.',
                }
            })
            .collect()
    }

    /// Drive a `Body` at constant velocity and collect the render path.
    fn body_path(x0: f32, y0: f32, vx: f32, vy: f32, n: usize) -> Vec<(i16, i16)> {
        let mut b = Body::new(x0, y0);
        let mut path = Vec::with_capacity(n);
        for _ in 0..n {
            path.push((b.draw_x(), b.draw_y()));
            b.move_by(vx, vy);
        }
        path
    }

    /// The naive model: independent per-axis accumulation, floored at draw.
    fn naive_path(x0: f32, y0: f32, vx: f32, vy: f32, n: usize) -> Vec<(i16, i16)> {
        let (mut x, mut y) = (x0, y0);
        let mut path = Vec::with_capacity(n);
        for _ in 0..n {
            path.push((floor_i16(x), floor_i16(y)));
            x += vx;
            y += vy;
        }
        path
    }

    /// The defining "no zigzag" property: the minor (slower) axis never steps
    /// on a frame the major (faster) axis does not. Equal speed counts x as
    /// major, matching `Body`.
    fn minor_never_steps_alone(path: &[(i16, i16)], vx: f32, vy: f32) -> bool {
        let x_major = fabs(vx) >= fabs(vy);
        path.windows(2).all(|w| {
            let (dx, dy) = (w[1].0 - w[0].0, w[1].1 - w[0].1);
            let (major, minor) = if x_major { (dx, dy) } else { (dy, dx) };
            minor == 0 || major != 0
        })
    }

    #[test]
    fn button_diagonal_is_a_clean_staircase_at_any_phase() {
        // The Right+Up case: a perfect 45° equal-speed heading. Naively this
        // zigzags whenever x and y start on different fractions; Body does not.
        for &(speed, y0) in &[(0.5_f32, 10.5_f32), (0.7, 10.3), (0.6, 10.91)] {
            let naive = classify(&naive_path(10.0, y0, speed, speed, 40));
            let body = classify(&body_path(10.0, y0, speed, speed, 40));
            assert!(
                naive.contains('V') && naive.contains('H'),
                "test setup: naive path should zigzag, got {naive}"
            );
            assert!(
                !body.contains('V') && !body.contains('H'),
                "Body diagonal should be pure D/.: got {body}"
            );
        }
    }

    #[test]
    fn matched_phase_diagonal_is_unchanged() {
        // When x and y already share a fraction the naive path is already clean;
        // Body must not make it worse.
        let body = classify(&body_path(10.0, 10.0, 0.5, 0.5, 32));
        assert_eq!(body, ".D.D.D.D.D.D.D.D.D.D.D.D.D.D.D.");
    }

    #[test]
    fn no_lone_minor_step_at_any_heading() {
        // Sweep headings (including off-45, which a cart could choose via
        // unequal axis speeds). Body must never emit a lone minor-axis step.
        for k in 1..18 {
            let deg = k as f32 * 5.0; // 5°..85°
            let rad = deg * core::f32::consts::PI / 180.0;
            // cos/sin via the std test build; values are just a fixed heading.
            let (vx, vy) = (0.7 * rad.cos(), 0.7 * rad.sin());
            let path = body_path(0.3, 0.6, vx, vy, 200);
            assert!(
                minor_never_steps_alone(&path, vx, vy),
                "lone minor step at {deg}°: {}",
                classify(&path)
            );
        }
    }

    #[test]
    fn render_tracks_true_position_within_one_pixel() {
        // The render pixel must stay within 1px of floor(true) for every
        // heading, and it must not drift with time.
        for k in 1..18 {
            let deg = k as f32 * 5.0;
            let rad = deg * core::f32::consts::PI / 180.0;
            let (vx, vy) = (0.7 * rad.cos(), 0.7 * rad.sin());
            let mut b = Body::new(3.3, 7.6);
            for _ in 0..50_000 {
                b.move_by(vx, vy);
                let ex = (b.draw_x() - floor_i16(b.x())).abs();
                let ey = (b.draw_y() - floor_i16(b.y())).abs();
                assert!(ex <= 1 && ey <= 1, "drift {ex},{ey} at {deg}°");
            }
        }
    }

    #[test]
    fn axis_aligned_and_fast_motion_render_exactly() {
        // Pure horizontal, pure vertical, and >=1px/frame motion have no zigzag
        // to fix, so the drawn pixel must equal floor(true) exactly.
        for &(vx, vy) in &[
            (0.5_f32, 0.0_f32),
            (0.0, -0.7),
            (1.0, 1.0),
            (1.7, 1.7),
            (2.3, -0.4),
        ] {
            let mut b = Body::new(10.0, 10.5);
            for _ in 0..300 {
                b.move_by(vx, vy);
                assert_eq!(b.draw_x(), floor_i16(b.x()));
                assert_eq!(b.draw_y(), floor_i16(b.y()));
            }
        }
    }

    #[test]
    fn direction_changes_stay_clean() {
        // Right, then Right+Up, then Up — like a player working the d-pad. No
        // segment, and no transition, may introduce a lone minor step.
        let mut b = Body::new(20.0, 20.3);
        let mut path = vec![(b.draw_x(), b.draw_y())];
        for (vx, vy, frames) in [(0.7, 0.0, 10), (0.7, -0.7, 16), (0.0, -0.7, 10)] {
            for _ in 0..frames {
                b.move_by(vx, vy);
                path.push((b.draw_x(), b.draw_y()));
            }
        }
        let s = classify(&path);
        // No orthogonal jitter anywhere across the segments or their seams...
        assert!(!zigzags(&s), "direction changes introduced a zigzag: {s}");
        // ...and the held diagonal really did produce diagonal steps.
        assert!(s.contains('D'), "expected a diagonal segment: {s}");
    }

    /// A path zigzags if an `H` and a `V` are adjacent — an orthogonal jitter
    /// rather than a monotone staircase.
    fn zigzags(s: &str) -> bool {
        let b = s.as_bytes();
        b.windows(2)
            .any(|w| (w[0] == b'H' && w[1] == b'V') || (w[0] == b'V' && w[1] == b'H'))
    }

    #[test]
    fn set_pos_teleports_and_resyncs_render() {
        let mut b = Body::new(0.0, 0.0);
        b.move_by(0.5, 0.5);
        b.set_pos(40.9, 12.2);
        assert_eq!(b.pos(), (40.9, 12.2));
        assert_eq!(b.draw_pos(), (40, 12));
    }

    #[test]
    fn negative_positions_floor_correctly() {
        let b = Body::new(-0.5, -2.0);
        assert_eq!(b.draw_pos(), (-1, -2));
    }

    #[test]
    fn floor_i16_saturates_instead_of_underflowing() {
        // Anything at or below i16::MIN as f32 must floor to i16::MIN, not
        // wrap past it — the bug this guards against panicked in debug and
        // silently teleported to i16::MAX in release.
        assert_eq!(floor_i16(-32768.5), i16::MIN);
        assert_eq!(floor_i16(-1e9), i16::MIN);
        assert_eq!(floor_i16(f32::NEG_INFINITY), i16::MIN);
        // Exactly i16::MIN is already covered above, but spell it out: no
        // spurious off-by-one at the boundary itself.
        assert_eq!(floor_i16(i16::MIN as f32), i16::MIN);
    }

    #[test]
    fn floor_i16_ordinary_values_unchanged() {
        assert_eq!(floor_i16(-0.5), -1);
        assert_eq!(floor_i16(3.9), 3);
        assert_eq!(floor_i16(i16::MAX as f32), i16::MAX);
    }

    #[test]
    fn floor_i16_nan_is_unchanged() {
        // Not a claim that this is "correct" — just pinning today's behavior
        // (the `as i16` cast maps NaN to 0) so this fix doesn't accidentally
        // change it.
        assert_eq!(floor_i16(f32::NAN), 0);
    }
}