close out the restructure's open items

- boot_info: label the ESP32 reset reasons Zephyr's hwinfo driver maps to 0
  (USB-Serial-JTAG/JTAG reset -> DEBUG, power glitch -> BROWNOUT, CPU lockup,
  eFuse -> HARDWARE); get pwrmgt.bootreason no longer says Unknown after a flash
- mqtt: the uplink credentials struct moves to adapters/mqtt/uplink_creds.h,
  so the adapter no longer includes an app header (layout unchanged: the
  struct is persisted raw)
- gps: one off-levels routine for standby/off and System OFF; the sleep line
  stays asserted in standby and drops at System OFF, as upstream's
  sleep_gps/stop_gps/powerOff; System OFF now asserts reset before cutting
  GPS_EN
- doom: sprites solid with a one-pixel black halo, boss drawn 1.5x on the
  floor; the L2 boss was invisible from the spawn on 1-bit panels
This commit is contained in:
liquidraver
2026-09-26 08:50:30 +02:00
parent 9fc1eaf18a
commit 187a44060d
8 changed files with 214 additions and 162 deletions
+5 -1
View File
@@ -85,6 +85,7 @@ zephcore/
│ ├── gps/ZephyrGPSManager.cpp/h # GNSS state machine, fix validation, public API
│ ├── gps/gps_power.cpp, gps_module_cfg.cpp, gps_internal.h # module power; module configuration + diag
│ ├── mqtt/ZephyrMQTTPublisher.c/h # MQTT packet publisher (observer / uplink)
│ ├── mqtt/uplink_creds.h # WiFi/MQTT/location credentials struct (persisted by app/observer_creds)
│ ├── ota/wifi_ota.c/h # WiFi SoftAP + HTTP firmware upload
│ ├── rng/ZephyrRNG.cpp/h # Hardware CSPRNG with PRNG fallback
│ ├── sensors/ # ZephyrSensorManager (upstream SensorManager) over the I2C env sensors + power monitors
@@ -1271,7 +1272,10 @@ cleared register. nRF implements `hwinfo_clear_reset_cause()`;
ESP32 does not, so the weak stub returns `-ENOSYS` and nothing is cleared
there. That is harmless on ESP32, whose cause comes from `esp_reset_reason()`
and does not accumulate across boots, but the capture is what readers should
rely on rather than the register.
rely on rather than the register. Zephyr's ESP32 hwinfo driver returns 0 for
the reasons it has no case for; `boot_info` maps those itself (USB-Serial-JTAG
and JTAG resets, i.e. every esptool flash, to `DEBUG`; power glitch to
`BROWNOUT`; CPU lockup; eFuse error to `HARDWARE`).
Everything below is software: bounded stall detection in the layer that owns the
state machine. Each entry names what it recovers, because several are
+4 -4
View File
@@ -185,7 +185,7 @@ flowchart TB
| V2 | `helpers/CommonCLI.cpp` includes the GPS manager, `ZephyrBoard`, WiFi OTA and UI headers (it serves `gps sync`, `start dfu`, `start ota`, display and input rotation). Sensors and GPS settings go through upstream's `SensorManager` since 2026-09-25. | Accepted: the remaining includes serve ZephCore-only commands. |
| V3 | `helpers/boot_prefs.h` includes the GPS manager (applies GPS prefs at boot, as upstream's `applyGpsPrefs()`). | Accepted. |
| V4 | The joystick UI calls `CompanionMesh` directly. | **Accepted by decision**: it runs on the main thread, so the calls are safe, and upstream's UITask reads `the_mesh` the same way. One access point (`JoystickUITask::getMesh()`). |
| V5 | The MQTT adapter includes `app/observer_creds.h`. | Open, small. |
| V5 | The MQTT adapter included `app/observer_creds.h`. | **Resolved 2026-09-26**: the credentials struct lives in `adapters/mqtt/uplink_creds.h`; the roles keep its persistence. |
### 5.4 Component inventory
@@ -358,6 +358,6 @@ upstream alignment (§9).
1. `CompanionMesh.cpp` (~3.5k lines) and `CommonCLI.cpp` (~2k) remain the largest files, though now in upstream's
shape (one handler per opcode; upstream command layout).
2. V5 (§5.3).
3. Merging the GPS standby and shutdown pin sequences needs current measurements first.
4. ESP32 reset cause is not reported (`get pwrmgt.bootreason` shows `Unknown`).
None beyond that: the items left open at the end of the restructure (V5, the GPS off sequences, the ESP32 reset
cause) were closed on 2026-09-26.
+57 -96
View File
@@ -151,6 +151,58 @@ static const struct gpio_dt_spec gps_resetb_gpio = GPIO_DT_SPEC_GET(DT_ALIAS(gps
static bool gps_gpio_configured = false;
#endif
/* The off levels, shared by gps_power_control(false) and System OFF: reset
* and resetb asserted (the module held in reset while its supply is gated,
* so it starts cleanly on the next power-on), the enable and rtcint
* de-asserted, VRTC de-asserted unless keep_vrtc (warm standby).
*
* The sleep line is the one difference: runtime standby/off leaves it
* asserted, System OFF drops it -- as upstream, whose T1000-E sleep_gps() and
* stop_gps() drive GPS_SLEEP_INT HIGH and only powerOff() drives it LOW.
*
* gpio_pin_configure_dt() on every line: a boot with the GPS off never ran
* the power-on path, so the pins may not be outputs yet. */
static void gps_drive_off_levels(bool keep_vrtc, bool system_off)
{
#if HAS_GPS_RESET
if (gpio_is_ready_dt(&gps_reset_gpio)) {
gpio_pin_configure_dt(&gps_reset_gpio, GPIO_OUTPUT_ACTIVE);
}
#endif
#if HAS_GPS_VRTC
if (!keep_vrtc && gpio_is_ready_dt(&gps_vrtc_gpio)) {
gpio_pin_configure_dt(&gps_vrtc_gpio, GPIO_OUTPUT_INACTIVE);
}
#else
ARG_UNUSED(keep_vrtc);
#endif
#if HAS_GPS_POWER_CONTROL
/* INACTIVE, not LOW: physical LOW leaves an active-low enable asserted. */
if (gpio_is_ready_dt(&gps_enable_gpio)) {
gpio_pin_configure_dt(&gps_enable_gpio, GPIO_OUTPUT_INACTIVE);
gps_gpio_configured = true;
}
#endif
#if HAS_GPS_SLEEP
if (system_off && gpio_is_ready_dt(&gps_sleep_gpio)) {
gpio_pin_configure_dt(&gps_sleep_gpio, GPIO_OUTPUT_INACTIVE);
}
#else
ARG_UNUSED(system_off);
#endif
#if HAS_GPS_RTCINT
if (gpio_is_ready_dt(&gps_rtcint_gpio)) {
gpio_pin_configure_dt(&gps_rtcint_gpio, GPIO_OUTPUT_INACTIVE);
}
#endif
#if HAS_GPS_RESETB
/* Declared GPIO_ACTIVE_LOW, so ACTIVE drives it LOW. */
if (gpio_is_ready_dt(&gps_resetb_gpio)) {
gpio_pin_configure_dt(&gps_resetb_gpio, GPIO_OUTPUT_ACTIVE);
}
#endif
}
/* GPS power control with warm standby support.
* @param on true = power on, false = power off
* @param keep_vrtc When powering off: true = keep VRTC alive (warm standby,
@@ -263,62 +315,7 @@ void gps_power_control(bool on, bool keep_vrtc)
}
#endif
} else {
/* Power off sequence */
#if HAS_GPS_RESET
/* Hold GPS in reset during power-off — matches Arduino sleep_gps()/stop_gps().
* Ensures chip sees RESET asserted when GPS_EN is asserted on next
* power-on, preventing uncontrolled startup before the reset pulse.
* Configure-on-first-use (mirrors the GPS_EN pin below): on a
* boot-with-GPS-off the power-on path never ran, so the pin isn't an
* output yet — gpio_pin_set_dt() alone would leave it floating instead
* of asserting reset. GPIO_OUTPUT_ACTIVE drives the active (asserted)
* level directly. */
if (gpio_is_ready_dt(&gps_reset_gpio)) {
if (!gps_gpio_configured) {
gpio_pin_configure_dt(&gps_reset_gpio, GPIO_OUTPUT_ACTIVE);
} else {
gpio_pin_set_dt(&gps_reset_gpio, 1);
}
}
#endif
#if HAS_GPS_VRTC
if (!keep_vrtc) {
/* Full power-off: VRTC off too (cold start on next wake) */
if (gpio_is_ready_dt(&gps_vrtc_gpio)) {
if (!gps_gpio_configured) {
gpio_pin_configure_dt(&gps_vrtc_gpio, GPIO_OUTPUT_INACTIVE);
} else {
gpio_pin_set_dt(&gps_vrtc_gpio, 0);
}
}
}
/* else: warm standby — VRTC stays asserted, preserving
* ephemeris/almanac/RTC for fast re-acquisition (~1-2 µA) */
#endif
if (gpio_is_ready_dt(&gps_enable_gpio)) {
if (!gps_gpio_configured) {
gpio_pin_configure_dt(&gps_enable_gpio, GPIO_OUTPUT_INACTIVE);
gps_gpio_configured = true;
} else {
gpio_pin_set_dt(&gps_enable_gpio, 0);
}
}
#if HAS_GPS_RESETB
/* Assert RESETB when GPS is off (Arduino sleep_gps/stop_gps).
* The alias is declared GPIO_ACTIVE_LOW, so this drives it LOW. */
if (gpio_is_ready_dt(&gps_resetb_gpio)) {
gpio_pin_configure_dt(&gps_resetb_gpio, GPIO_OUTPUT_ACTIVE);
}
#endif
#if HAS_GPS_RTCINT
/* GPS_RTC_INT stays de-asserted during sleep/off (same as normal operation) */
if (gpio_is_ready_dt(&gps_rtcint_gpio)) {
gpio_pin_configure_dt(&gps_rtcint_gpio, GPIO_OUTPUT_INACTIVE);
}
#endif
gps_drive_off_levels(keep_vrtc, false);
#if HAS_GPS_VRTC
LOG_INF("GPS power OFF (%s)", keep_vrtc ?
@@ -332,12 +329,9 @@ void gps_power_control(bool on, bool keep_vrtc)
#endif
}
/* Put every GPS control line this board declares into its System OFF state:
* the power enable, and where present VRTC, sleep and rtcint, de-asserted;
* reset and resetb asserted, holding the module in reset while its supply is
* gated -- the same levels gps_power_control(false) leaves them at.
* Uses gpio_pin_configure_dt() so pins are properly set even if
* gps_power_control() was never called (GPIO not yet configured). */
/* Put every GPS control line this board declares into its System OFF state
* (see gps_drive_off_levels()), the regulator rail off and VRTC off too.
* Configures the pins, so it works even if gps_power_control() never ran. */
void gps_power_off_for_shutdown(void)
{
#if HAS_GPS_POWER_REGULATOR
@@ -346,40 +340,7 @@ void gps_power_off_for_shutdown(void)
gps_reg_enabled = false;
}
#endif
#if HAS_GPS_POWER_CONTROL
/* INACTIVE: physical LOW leaves an active-low enable asserted, i.e. the
* module still powered across System OFF. */
if (gpio_is_ready_dt(&gps_enable_gpio)) {
gpio_pin_configure_dt(&gps_enable_gpio, GPIO_OUTPUT_INACTIVE);
}
#endif
#if HAS_GPS_VRTC
if (gpio_is_ready_dt(&gps_vrtc_gpio)) {
gpio_pin_configure_dt(&gps_vrtc_gpio, GPIO_OUTPUT_INACTIVE);
}
#endif
#if HAS_GPS_RESET
/* ACTIVE, matching the power-off path: reset stays asserted across
* System OFF, as Arduino stop_gps() leaves it. */
if (gpio_is_ready_dt(&gps_reset_gpio)) {
gpio_pin_configure_dt(&gps_reset_gpio, GPIO_OUTPUT_ACTIVE);
}
#endif
#if HAS_GPS_SLEEP
if (gpio_is_ready_dt(&gps_sleep_gpio)) {
gpio_pin_configure_dt(&gps_sleep_gpio, GPIO_OUTPUT_INACTIVE);
}
#endif
#if HAS_GPS_RTCINT
if (gpio_is_ready_dt(&gps_rtcint_gpio)) {
gpio_pin_configure_dt(&gps_rtcint_gpio, GPIO_OUTPUT_INACTIVE);
}
#endif
#if HAS_GPS_RESETB
if (gpio_is_ready_dt(&gps_resetb_gpio)) {
gpio_pin_configure_dt(&gps_resetb_gpio, GPIO_OUTPUT_ACTIVE);
}
#endif
gps_drive_off_levels(false, true);
}
#if HAS_GNSS
+1 -1
View File
@@ -13,7 +13,7 @@
#include "ZephyrMQTTPublisher.h"
#include "ZephyrWiFiStation.h" /* g_wifi_events, WIFI_READY_BIT, zc_wifi_station_* */
#include "observer_creds.h"
#include "uplink_creds.h"
#include <zephyr/kernel.h>
#include <zephyr/net/mqtt.h>
+42
View File
@@ -0,0 +1,42 @@
/*
* SPDX-License-Identifier: MIT
* Uplink credentials: WiFi, MQTT broker and location for the observer and the
* repeater uplink. Read by the WiFi station and the MQTT publisher; loaded,
* saved and edited by the roles (app/observer_creds.h).
*
* The struct is persisted as raw bytes (/lfs/repeater/obs_creds): keep the
* field order and sizes.
*/
#pragma once
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
struct ObserverCreds {
char wifi_ssid[64]; /* WiFi network name */
char wifi_psk[64]; /* WiFi password (empty = open network) */
char mqtt_host[128]; /* MQTT broker hostname, e.g. "abydos.hu" */
uint16_t mqtt_port; /* MQTT broker port, e.g. 8883 */
uint8_t mqtt_tls; /* 1 = TLS (no cert verify), 0 = plaintext */
char mqtt_user[64]; /* MQTT username */
char mqtt_password[64]; /* MQTT password */
char mqtt_iata[8]; /* IATA location code, e.g. "BUD", "BTS", "VIE" */
uint8_t _reserved[1]; /* alignment / future use */
int32_t lat_e6; /* latitude × 1 000 000 (e.g. 47497900 = 47.497900°N) */
int32_t lon_e6; /* longitude × 1 000 000 (e.g. 19040200 = 19.040200°E) */
};
/* Apply sensible defaults to a freshly-zeroed creds struct. */
static inline void observer_creds_init(struct ObserverCreds *creds)
{
creds->mqtt_port = 8883;
creds->mqtt_tls = 1;
}
#ifdef __cplusplus
}
#endif
+2 -21
View File
@@ -11,24 +11,12 @@
#include <stdint.h>
#include <stdbool.h>
#include "../adapters/mqtt/uplink_creds.h" /* struct ObserverCreds, observer_creds_init() */
#ifdef __cplusplus
extern "C" {
#endif
struct ObserverCreds {
char wifi_ssid[64]; /* WiFi network name */
char wifi_psk[64]; /* WiFi password (empty = open network) */
char mqtt_host[128]; /* MQTT broker hostname, e.g. "abydos.hu" */
uint16_t mqtt_port; /* MQTT broker port, e.g. 8883 */
uint8_t mqtt_tls; /* 1 = TLS (no cert verify), 0 = plaintext */
char mqtt_user[64]; /* MQTT username */
char mqtt_password[64]; /* MQTT password */
char mqtt_iata[8]; /* IATA location code, e.g. "BUD", "BTS", "VIE" */
uint8_t _reserved[1]; /* alignment / future use */
int32_t lat_e6; /* latitude × 1 000 000 (e.g. 47497900 = 47.497900°N) */
int32_t lon_e6; /* longitude × 1 000 000 (e.g. 19040200 = 19.040200°E) */
};
/* Load creds from /lfs/repeater/obs_creds.
* Returns true on success; on failure fills struct with safe zero defaults. */
bool observer_creds_load(struct ObserverCreds *creds, const char *base_path);
@@ -36,13 +24,6 @@ bool observer_creds_load(struct ObserverCreds *creds, const char *base_path);
/* Save creds to /lfs/repeater/obs_creds. Returns true on success. */
bool observer_creds_save(const struct ObserverCreds *creds, const char *base_path);
/* Apply sensible defaults to a freshly-zeroed creds struct. */
static inline void observer_creds_init(struct ObserverCreds *creds)
{
creds->mqtt_port = 8883;
creds->mqtt_tls = 1;
}
#ifdef __cplusplus
}
#endif
+33
View File
@@ -12,11 +12,38 @@
#include <stdio.h>
#if defined(CONFIG_SOC_FAMILY_ESPRESSIF_ESP32)
#include <esp_system.h>
#endif
LOG_MODULE_REGISTER(zephcore_boot, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL);
static uint32_t s_reset_cause;
static bool s_reset_cause_valid;
#if defined(CONFIG_SOC_FAMILY_ESPRESSIF_ESP32)
/* Zephyr's ESP32 hwinfo driver reports 0 for the reasons it has no case for,
* the commonest being ESP_RST_USB: esptool's reset over USB-Serial-JTAG, i.e.
* every flash. Map those onto the nearest RESET_* bit so they are labelled
* instead of "Unknown". */
static uint32_t esp_unmapped_reset_cause(void)
{
switch (esp_reset_reason()) {
case ESP_RST_USB:
case ESP_RST_JTAG:
return RESET_DEBUG;
case ESP_RST_PWR_GLITCH:
return RESET_BROWNOUT;
case ESP_RST_CPU_LOCKUP:
return RESET_CPU_LOCKUP;
case ESP_RST_EFUSE:
return RESET_HARDWARE;
default:
return 0;
}
}
#endif
/* POST_KERNEL: before main() on every role. */
static int boot_info_init(void)
{
@@ -27,6 +54,12 @@ static int boot_info_init(void)
return 0;
}
#if defined(CONFIG_SOC_FAMILY_ESPRESSIF_ESP32)
if (cause == 0) {
cause = esp_unmapped_reset_cause();
}
#endif
s_reset_cause = cause;
s_reset_cause_valid = true;
+70 -39
View File
@@ -606,8 +606,8 @@ static const uint16_t spr_portal_b[16] = {
/*
* Fireball — a solid ball confined to the middle 8 rows of the 16×16 cell, so
* it renders at roughly half an enemy's apparent size without the shared
* sprite blitter needing a scale argument. Solid, unlike the outlined portal
* it renders at eye level, at roughly half an enemy's apparent size, through
* the shared blitter at scale 100. Solid, unlike the outlined portal
* diamond, so the two never read as the same thing.
*/
static const uint16_t spr_fireball[16] = {
@@ -663,14 +663,41 @@ static const uint16_t *get_sprite_16(const struct doom_enemy *e)
/* ========== RAYCASTER ========== */
/* The sprite texel under screen pixel (sc, sr) of a sprite drawn at
* [x0, x0 + w) x [y0, y0 + h). */
static inline bool sprite_texel_on(const uint16_t *sprite, int sc, int sr,
int x0, int y0, int w, int h)
{
int tex_x = (sc - x0) * 16 / w;
int tex_y = (sr - y0) * 16 / h;
if (tex_x > 15) tex_x = 15;
if (tex_y > 15) tex_y = 15;
return (sprite[tex_y] & (0x8000 >> tex_x)) != 0;
}
static inline void clear_px(int x, int y)
{
render_fb[(y / 8) * SCREEN_W + x] &= (uint8_t)~(1U << (y & 7));
}
/*
* Render one 16×16 sprite at world position (wx, wy).
* Shared by both the portal and all enemy sprites.
* Render one 16×16 sprite at world position (wx, wy), scale_pct percent of a
* wall's height with its feet on the floor. Shared by the portal, the enemies
* and the fireballs.
*
* Sprites are solid with a one-pixel black halo, not distance-dithered like
* the walls. On a 1-bit panel a dithered sprite ORed over the dithered floor
* band at the horizon has nothing to contrast against: the L2 boss, which
* starts 8-12 tiles away, was a few dots at best and a grey smudge in the
* floor's own pattern at 4 tiles. The halo pass clears first, then the fill
* sets; both honour the zbuffer, so a nearer wall is never cut into.
*/
static void render_one_sprite(const uint16_t *sprite,
fixed_t wx, fixed_t wy,
fixed_t inv_det,
struct doom_player *p)
struct doom_player *p,
int scale_pct)
{
fixed_t sx = wx - p->x;
fixed_t sy = wy - p->y;
@@ -686,46 +713,49 @@ static void render_one_sprite(const uint16_t *sprite,
fp_to_int(fp_div(fp_mul(transform_x,
fp_from_int(SCREEN_W)), transform_y));
int sprite_h = fp_to_int(fp_abs(fp_div(
int base_h = fp_to_int(fp_abs(fp_div(
fp_from_int(SCREEN_H), transform_y)));
if (sprite_h < 3) return;
if (sprite_h > SCREEN_H * 2) sprite_h = SCREEN_H * 2;
if (base_h > SCREEN_H * 2) base_h = SCREEN_H * 2;
int draw_start_y = (SCREEN_H - sprite_h) / 2;
int draw_end_y = draw_start_y + sprite_h;
int sprite_h = base_h * scale_pct / 100;
if (sprite_h < 3) return;
int draw_end_y = (SCREEN_H + base_h) / 2; /* the floor line */
int draw_start_y = draw_end_y - sprite_h;
int draw_start_x = sprite_screen_x - sprite_h / 2;
int draw_end_x = sprite_screen_x + sprite_h / 2;
int sprite_w = draw_end_x - draw_start_x;
/* Floor for sprites: at 8+ tiles the wall fade (brightness 1) passes one
* dither cell in 16, so the boss, which starts ~11 tiles away in a
* corner, drew as a few dots or nothing while its fireballs hit. Walls
* keep the full fade; sprites keep a silhouette. */
int brightness = dist_to_brightness(transform_y);
if (brightness < 7) {
brightness = 7;
}
int x_lo = draw_start_x < 0 ? 0 : draw_start_x;
int x_hi = draw_end_x > SCREEN_W ? SCREEN_W : draw_end_x;
int y_lo = draw_start_y < 0 ? 0 : draw_start_y;
int y_hi = draw_end_y > SCREEN_H ? SCREEN_H : draw_end_y;
for (int sc = draw_start_x; sc < draw_end_x; sc++) {
if (sc < 0 || sc >= SCREEN_W) continue;
if (transform_y >= zbuffer[sc]) continue;
for (int pass = 0; pass < 2; pass++) {
for (int sc = x_lo; sc < x_hi; sc++) {
if (transform_y >= zbuffer[sc]) continue;
int tex_x = (sc - draw_start_x) * 16 / sprite_h;
if (tex_x < 0) tex_x = 0;
if (tex_x > 15) tex_x = 15;
for (int sr = draw_start_y; sr < draw_end_y; sr++) {
if (sr < 0 || sr >= SCREEN_H) continue;
int tex_y = (sr - draw_start_y) * 16 / sprite_h;
if (tex_y < 0) tex_y = 0;
if (tex_y > 15) tex_y = 15;
if (sprite[tex_y] & (0x8000 >> tex_x)) {
uint8_t threshold =
bayer4x4[sr & 3][sc & 3];
if (brightness > threshold) {
for (int sr = y_lo; sr < y_hi; sr++) {
if (!sprite_texel_on(sprite, sc, sr,
draw_start_x, draw_start_y,
sprite_w, sprite_h)) {
continue;
}
if (pass == 1) {
render_fb[(sr / 8) * SCREEN_W + sc] |=
(1U << (sr & 7));
continue;
}
for (int hx = sc - 1; hx <= sc + 1; hx++) {
if (hx < 0 || hx >= SCREEN_W ||
transform_y >= zbuffer[hx]) {
continue;
}
for (int hy = sr - 1; hy <= sr + 1; hy++) {
if (hy >= 0 && hy < SCREEN_H) {
clear_px(hx, hy);
}
}
}
}
}
@@ -748,7 +778,7 @@ static void render_sprites(void)
render_one_sprite(pspr,
fp_from_int(game.portal_map_x) + FP_HALF,
fp_from_int(game.portal_map_y) + FP_HALF,
inv_det, p);
inv_det, p, 100);
}
/* Collect visible enemies and sort farthest-first */
@@ -781,7 +811,8 @@ static void render_sprites(void)
for (int i = 0; i < visible; i++) {
struct doom_enemy *e = &game.enemies[order[i].idx];
render_one_sprite(get_sprite_16(e), e->x, e->y, inv_det, p);
render_one_sprite(get_sprite_16(e), e->x, e->y, inv_det, p,
e->type == ENEMY_BOSS ? 150 : 100);
}
/*
@@ -793,7 +824,7 @@ static void render_sprites(void)
for (int i = 0; i < MAX_FIREBALLS; i++) {
struct doom_fireball *f = &game.fireballs[i];
if (!f->active) continue;
render_one_sprite(spr_fireball, f->x, f->y, inv_det, p);
render_one_sprite(spr_fireball, f->x, f->y, inv_det, p, 100);
}
}