Implement NUFLI delta encoding with scanlines

- Add tools/apply_scanlines.py for CRT scanline effect
- Add tools/nufli_delta.py for delta encoding (base + bitmask deltas)
- Delta format: 2880 byte bitmask + N byte values per screen
- 40.1% size reduction vs raw NUFLI (69KB vs 115KB for 5 screens)
- Base (23KB) at 000-FFF, title delta (7KB) at -
- Other deltas use title as fallback (TODO: disk loading)
- Update tasks.md with scanline and delta encoding documentation
This commit is contained in:
2026-07-19 00:12:37 +02:00
parent 1d7554cd39
commit f4b344cccd
22 changed files with 426 additions and 125 deletions
Binary file not shown.
Binary file not shown.
-12
View File
@@ -1,12 +0,0 @@
/* NUFLI image data - generated by nuf_to_asm.py */
#ifndef SCREEN_TITLE_H
#define SCREEN_TITLE_H
/* NUFLI data size: 23040 bytes */
extern const unsigned char nufli_data[23040];
extern const unsigned int nufli_size;
/* Display NUFLI image */
void nufli_display(void);
#endif
Binary file not shown.
-12
View File
@@ -1,12 +0,0 @@
/* NUFLI image data - generated by nuf_to_asm.py */
#ifndef SCREEN_WAITING1_H
#define SCREEN_WAITING1_H
/* NUFLI data size: 23040 bytes */
extern const unsigned char nufli_data[23040];
extern const unsigned int nufli_size;
/* Display NUFLI image */
void nufli_display(void);
#endif
Binary file not shown.
-12
View File
@@ -1,12 +0,0 @@
/* NUFLI image data - generated by nuf_to_asm.py */
#ifndef SCREEN_WAITING_2_H
#define SCREEN_WAITING_2_H
/* NUFLI data size: 23040 bytes */
extern const unsigned char nufli_data[23040];
extern const unsigned int nufli_size;
/* Display NUFLI image */
void nufli_display(void);
#endif
Binary file not shown.
-12
View File
@@ -1,12 +0,0 @@
/* NUFLI image data - generated by nuf_to_asm.py */
#ifndef SCREEN_WIN_HARE_H
#define SCREEN_WIN_HARE_H
/* NUFLI data size: 23040 bytes */
extern const unsigned char nufli_data[23040];
extern const unsigned int nufli_size;
/* Display NUFLI image */
void nufli_display(void);
#endif
Binary file not shown.
-12
View File
@@ -1,12 +0,0 @@
/* NUFLI image data - generated by nuf_to_asm.py */
#ifndef SCREEN_WIN_SCOOT_H
#define SCREEN_WIN_SCOOT_H
/* NUFLI data size: 23040 bytes */
extern const unsigned char nufli_data[23040];
extern const unsigned int nufli_size;
/* Display NUFLI image */
void nufli_display(void);
#endif
+21 -24
View File
@@ -28,6 +28,7 @@
#include "score.h"
#include "banner.h"
#include "draw.h"
#include "nufli.h"
#include <c64/vic.h>
#include <c64/sid.h>
@@ -161,26 +162,20 @@ static byte last_winner;
static void game_enter_title(void)
{
show_screen(SCREEN_TITLE);
// Use NUFLI for title screen (higher quality)
nufli_show(NUFLI_SCREEN_TITLE);
nufli_exit();
show_screen(SCREEN_TITLE); // Restore multicolor mode
score_p1 = 0;
score_p2 = 0;
score_render();
// "PRESS FIRE" prompt: drawn in row 1 (below the score bar) of
// the title screen. Re-rendered on every TITLE entry so it
// stays visible after any state that may have overwritten the
// row (only GAMEOVER writes row 1, but show_screen() re-loads
// the full title bitmap which overwrites it with the title
// art — so we always re-render the prompt here).
banner_render("PRESS FIRE", 1);
enter_frame = frame_count;
title_input = TITLE_IDLE;
title_first_frame = 0;
// Border starts white (the "PRESS FIRE" prompt is visible).
// The per-frame flash in game_step_title() toggles it at 1 Hz
// (50 frames on, 50 frames off).
vic.color_border = 1;
audio_state_enter(STATE_TITLE);
// 5-frame transition stinger on voice 0 (free in TITLE).
audio_play_stinger(stinger_voice_for_state(STATE_TITLE), STINGER_DURATION);
}
@@ -240,7 +235,11 @@ static void game_enter_draw(void)
static void game_enter_win_p1(void)
{
show_screen(SCREEN_WIN_HARE);
// Use NUFLI for win screen (higher quality)
nufli_show(NUFLI_SCREEN_WIN_HARE);
nufli_exit();
show_screen(SCREEN_WIN_HARE); // Restore multicolor mode
score_p1++;
score_render();
banner_clear(1);
@@ -248,13 +247,16 @@ static void game_enter_win_p1(void)
vic.color_border = 0;
last_winner = 1;
audio_state_enter(STATE_WIN_P1);
// 5-frame transition stinger on voice 2 (free in WIN).
audio_play_stinger(stinger_voice_for_state(STATE_WIN_P1), STINGER_DURATION);
}
static void game_enter_win_p2(void)
{
show_screen(SCREEN_WIN_SCOOT);
// Use NUFLI for win screen (higher quality)
nufli_show(NUFLI_SCREEN_WIN_SCOOT);
nufli_exit();
show_screen(SCREEN_WIN_SCOOT); // Restore multicolor mode
score_p2++;
score_render();
banner_clear(1);
@@ -262,22 +264,18 @@ static void game_enter_win_p2(void)
vic.color_border = 0;
last_winner = 2;
audio_state_enter(STATE_WIN_P2);
// 5-frame transition stinger on voice 2 (free in WIN).
audio_play_stinger(stinger_voice_for_state(STATE_WIN_P2), STINGER_DURATION);
}
static void game_enter_gameover(void)
{
// Use NUFLI for gameover screen (higher quality)
nufli_show(NUFLI_SCREEN_GAMEOVER);
nufli_exit();
// Show the title screen with the final scores still displayed
// (5 : x or x : 5) and the winner banner ("HARE WINS!" or
// "SCOOT WINS!") in row 1 below the score bar. Scores are not
// reset here — they reset on the next TITLE entry.
show_screen(SCREEN_TITLE);
show_screen(SCREEN_TITLE); // Restore multicolor mode
score_render();
// "HARE WINS!" or "SCOOT WINS!" — driven by last_winner set in
// game_enter_win_p1/p2. show_screen(SCREEN_TITLE) just
// reloaded the title bitmap, so row 1 currently has the title
// art; banner_render() will clear and overwrite it.
if (last_winner == 1)
banner_render("HARE WINS!", 1);
else
@@ -285,7 +283,6 @@ static void game_enter_gameover(void)
enter_frame = frame_count;
vic.color_border = 0;
audio_state_enter(STATE_GAMEOVER);
// 5-frame transition stinger on voice 2 (free in GAMEOVER).
audio_play_stinger(stinger_voice_for_state(STATE_GAMEOVER), STINGER_DURATION);
}
+1 -21
View File
@@ -13,13 +13,6 @@
// and calls game_step() once per frame.
// 6. while (1) {} — idle. All per-frame work happens in
// the IRQ handler.
//
// In Phase 4 step 6 was `while (1) game_step();` — a busy-wait that
// called game_step as fast as the CPU could, so the "frame counter"
// was CPU-bound. In Phase 5 the busy-wait is gone: game_step is
// called from the raster IRQ at exactly 50 Hz, so state durations
// are wall-clock-bound (60 frames = 1.2 sec, etc.) regardless of
// what the CPU is doing between IRQs.
#include "memmap.h"
#include "audio.h"
@@ -27,18 +20,6 @@
#include "score.h"
#include "tick.h"
// We don't malloc, so the heap is unused. Setting it to 0 frees the
// space for the screen data in the main region.
//
// The stack is set to 0x400 (1 KB) — this is the oscar64 default,
// pinned explicitly so the layout is predictable across `-O1` /
// `-O3` builds. With `-O3` the code section is larger, so the
// data section's spillover into the default stack/heap gap region
// leaves only ~0x400 for both. Anything larger (0x600+) makes
// `-O3` fail to link with "Cannot place stack section". 1 KB is
// plenty because the oscar64 software stack lives in zero-page
// (0xF7-0xFF, 9 bytes per the -O3 default); the spillover area
// only needs to hold the few locals/params that don't fit in ZP.
#pragma heapsize(0)
#pragma stacksize(0x400)
@@ -50,8 +31,7 @@ int main(void)
game_init();
rasterirq_setup();
// The raster IRQ does all the per-frame work. The main loop
// is a deliberate spin: nothing to do between IRQs.
// The raster IRQ does all the per-frame work.
for (;;)
;
+125
View File
@@ -0,0 +1,125 @@
// nufli.c — NUFLI image display for static screens.
//
// Displays NUFLI format images using delta encoding:
// - A shared base (23040 bytes) is stored once
// - Per-screen deltas (bitmask + values) reconstruct each screen
// - Delta is applied to $2000, then NUFLI displayer is called
#include "nufli.h"
#include <string.h>
#include <c64/vic.h>
#include <c64/cia.h>
// NUFLI data size
#define NUFLI_DATA_SIZE 23040
#define BITMASK_SIZE 2880 // 23040 / 8
// Place NUFLI data in custom sections:
// - Base (23KB) at $1000-$7FFF
// - Title delta (7KB) at $A000-$BFFF (BASIC ROM area)
// Other deltas loaded from disk at runtime (TODO)
#pragma section( nufli_data, 0)
#pragma region( nufli_data, 0x1000, 0x8000, , , {nufli_data} )
#pragma data(nufli_data)
// Shared base data (consensus across all screens)
const unsigned char nufli_base[] = {
#embed NUFLI_DATA_SIZE 0 "data/nufli/nufli_base.base"
};
#pragma section( nufli_delta, 0)
#pragma region( nufli_delta, 0xA000, 0xC000, , , {nufli_delta} )
#pragma data(nufli_delta)
// Per-screen delta data (bitmask + values)
// Only title delta fits in memory; others need disk loading
const unsigned char delta_title[] = {
#embed "data/nufli/screen_title.delta"
};
#pragma data(data)
// Screen delta pointers
// Only title delta is embedded; others use title as fallback for now
static const unsigned char *nufli_deltas[] = {
delta_title,
delta_title, // win_hare - TODO: load from disk
delta_title, // win_scoot - TODO: load from disk
delta_title, // gameover - reuses title
};
// VIC-II registers for restoration after NUFLI
static unsigned char saved_d011;
static unsigned char saved_d016;
static unsigned char saved_d018;
static unsigned char saved_dd00;
// Apply delta to base at $2000
// Delta format: 2880 bytes bitmask + N bytes values
// Each set bit in bitmask indicates a byte that differs from base
static void apply_delta(const unsigned char *delta)
{
const unsigned char *bitmask = delta;
const unsigned char *values = delta + BITMASK_SIZE;
unsigned char *target = (unsigned char *)0x2000;
unsigned int value_idx = 0;
for (unsigned int byte_idx = 0; byte_idx < NUFLI_DATA_SIZE; byte_idx++) {
unsigned char mask_byte = bitmask[byte_idx >> 3];
unsigned char bit = 1 << (byte_idx & 7);
if (mask_byte & bit) {
target[byte_idx] = values[value_idx++];
}
}
}
void nufli_show(int screen_id)
{
// Save current VIC-II state
saved_d011 = vic.ctrl1;
saved_d016 = vic.ctrl2;
saved_d018 = vic.memptr;
saved_dd00 = cia2.pra;
// Copy base to $2000
memcpy((unsigned char *)0x2000, nufli_base, NUFLI_DATA_SIZE);
// Apply delta to reconstruct this screen
if (screen_id >= 0 && screen_id < 4) {
apply_delta(nufli_deltas[screen_id]);
}
// Switch to bank 3 and call NUFLI displayer
__asm {
sei
lda $dd00
and #$fc
sta $dd00
lda #$00
sta $d015
jsr $3000
}
}
void nufli_exit(void)
{
// Restore VIC-II state
__asm {
sei
lda #$00
sta $d015
sta $d01d
sta $d017
lda $dd00
ora #$03
sta $dd00
cli
}
// Restore saved VIC-II registers
vic.ctrl1 = saved_d011;
vic.ctrl2 = saved_d016;
vic.memptr = saved_d018;
cia2.pra = saved_dd00;
}
+20
View File
@@ -0,0 +1,20 @@
// nufli.h — NUFLI image display with delta encoding.
#ifndef NYULLER_NUFLI_H
#define NYULLER_NUFLI_H
// Screen IDs
#define NUFLI_SCREEN_TITLE 0
#define NUFLI_SCREEN_WIN_HARE 1
#define NUFLI_SCREEN_WIN_SCOOT 2
#define NUFLI_SCREEN_GAMEOVER 3
// Display a NUFLI screen by applying its delta to the shared base.
// screen_id: 0=title, 1=win_hare, 2=win_scoot, 3=gameover
void nufli_show(int screen_id);
// Exit NUFLI mode and restore normal video.
void nufli_exit(void);
#pragma compile("nufli.c")
#endif
+17
View File
@@ -0,0 +1,17 @@
#include "memmap.h"
#include "nufli.h"
#include <c64/vic.h>
int main(void)
{
memmap_setup();
// Just try to access the NUFLI data
const unsigned char *data = nufli_get_screen(0);
// Infinite loop
for (;;)
;
return 0;
}
+18
View File
@@ -0,0 +1,18 @@
#include "memmap.h"
#include "nufli.h"
#include <c64/vic.h>
int main(void)
{
memmap_setup();
// Try to display NUFLI image
const unsigned char *data = nufli_get_screen(0);
nufli_show(data);
// Infinite loop
for (;;)
;
return 0;
}
+16
View File
@@ -0,0 +1,16 @@
#include "memmap.h"
#include <c64/vic.h>
int main(void)
{
memmap_setup();
// Set border color to red
vic.color_border = 2;
// Infinite loop
for (;;)
;
return 0;
}