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
+25 -20
View File
@@ -148,39 +148,44 @@ NUFLI_SCREENS := screen_title screen_waiting1 screen_waiting_2 screen_win_hare s
# Generated files
NUFLI_NUF_FILES := $(addprefix $(NUFLI_OUT_DIR)/,$(addsuffix .nuf,$(NUFLI_SCREENS)))
NUFLI_ASM_FILES := $(addprefix $(NUFLI_OUT_DIR)/,$(addsuffix .asm,$(NUFLI_SCREENS)))
NUFLI_HDR_FILES := $(addprefix $(NUFLI_OUT_DIR)/,$(addsuffix .h,$(NUFLI_SCREENS)))
NUFLI_DELTA_FILES := $(addprefix $(NUFLI_OUT_DIR)/,$(addsuffix .delta,$(NUFLI_SCREENS)))
NUFLI_BASE_FILE := $(NUFLI_OUT_DIR)/nufli_base.base
# Build all NUFLI images
nufli: $(NUFLI_ASM_FILES) $(NUFLI_HDR_FILES)
@echo "NUFLI images built in $(NUFLI_OUT_DIR)/"
# Build all NUFLI images (delta encoded)
nufli: $(NUFLI_BASE_FILE) $(NUFLI_DELTA_FILES)
@echo "NUFLI delta-encoded images built in $(NUFLI_OUT_DIR)/"
@ls -la $(NUFLI_BASE_FILE) $(NUFLI_DELTA_FILES) | awk '{print $$5, $$9}'
# Clean NUFLI artifacts
nufli-clean:
@rm -f $(NUFLI_OUT_DIR)/*.nuf $(NUFLI_OUT_DIR)/*.asm $(NUFLI_OUT_DIR)/*.h
@rm -f $(NUFLI_OUT_DIR)/*.bmp
@rm -f $(NUFLI_OUT_DIR)/*.nuf $(NUFLI_OUT_DIR)/*.delta $(NUFLI_OUT_DIR)/*.base
@rm -f $(NUFLI_OUT_DIR)/*.bmp $(NUFLI_OUT_DIR)/*.asm $(NUFLI_OUT_DIR)/*.h
@echo "cleaned NUFLI images"
# PNG → BMP (Mufflon needs BMP input)
$(NUFLI_OUT_DIR)/%.bmp: $(NUFLI_SRC_DIR)/%.png | ensure-build-dir
# Explicit rules for each screen (no pattern chain, no intermediate files)
# Apply scanlines: darken every other row for CRT effect + smaller NUFLI output
define NUFLI_RULES
$(NUFLI_OUT_DIR)/$(1).bmp: $(NUFLI_SRC_DIR)/$(1).png | ensure-build-dir
@mkdir -p $(NUFLI_OUT_DIR)
@echo "converting $< -> $@"
@python3 -c "from PIL import Image; Image.open('$<').resize((320,200), Image.LANCZOS).save('$@')"
@echo "converting $$< -> $$@ (with scanlines)"
@python3 $(TOOLS_DIR)/apply_scanlines.py $$< $$@
# BMP → NUFLI (.nuf)
$(NUFLI_OUT_DIR)/%.nuf: $(NUFLI_OUT_DIR)/%.bmp | ensure-mufflon
@echo "converting $< -> $@"
@$(MUFFLON_BIN) $< -o $@ --shutup
$(NUFLI_OUT_DIR)/$(1).nuf: $(NUFLI_OUT_DIR)/$(1).bmp | ensure-mufflon
@echo "converting $$< -> $$@"
@$(MUFFLON_BIN) $$< -o $$@ --shutup
endef
# NUFLI → Assembly (.asm + .h)
$(NUFLI_OUT_DIR)/%.asm $(NUFLI_OUT_DIR)/%.h: $(NUFLI_OUT_DIR)/%.nuf
@echo "converting $< -> $@ + $(basename $@).h"
@python3 $(TOOLS_DIR)/nuf_to_asm.py $< $(NUFLI_OUT_DIR)/$*
$(foreach screen,$(NUFLI_SCREENS),$(eval $(call NUFLI_RULES,$(screen))))
# Delta encoding: all .nuf files -> base + per-screen deltas
$(NUFLI_BASE_FILE) $(NUFLI_DELTA_FILES) &: $(NUFLI_NUF_FILES)
@echo "delta encoding $(words $(NUFLI_NUF_FILES)) screens..."
@python3 $(TOOLS_DIR)/nufli_delta.py $(NUFLI_OUT_DIR)/nufli_base $(NUFLI_NUF_FILES)
# ============================================================
# $(PRG) — compile main.c → nyuller.prg
# ============================================================
$(PRG): $(SRC_FILES) $(HDR_FILES) $(NUFLI_ASM_FILES) | ensure-build-dir ensure-oscar64
$(PRG): $(SRC_FILES) $(HDR_FILES) $(NUFLI_BASE_FILE) $(NUFLI_DELTA_FILES) | ensure-build-dir ensure-oscar64
@echo "compiling $(SRC) with $(OSCAR64_BIN) -> $(BUILD_DIR)/"
cd "$(SRC_DIR)" && "$(OSCAR64_BIN)" -i="$(OSCAR64_DIR)/include" -o="$(PRG)" $(OPT_FLAGS) "$(SRC)"
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;
}
+58
View File
@@ -377,6 +377,64 @@ with ~10+ colors per block vs the current 160×200 with 4 colors per cell.
- NUFLI uses all 8 sprites (no sprites for game objects)
- Static screens don't need game logic, so NUFLI is perfect
### Art Direction: Shared Backgrounds
**Problem:** Current source images have completely different backgrounds.
Only 2-4% pixel-identical across screens. NUFLI output is only 40% shared.
**Solution:** All screens should share the same background (or a small set).
Only foreground elements (characters, text, UI) should vary between screens.
**Recommendation for artist:**
1. Pick one background (e.g., the sunset/landscape from the title screen)
2. Use it as the base for ALL static screens
3. Only vary foreground elements (characters, text overlays)
4. With shared backgrounds, NUFLI overlap should reach 80-90%
**Expected impact:** With shared backgrounds, storing one base + per-screen
delta becomes practical (~10KB base + ~5KB per screen delta vs 23KB each).
### Scanline Rendering
**Problem:** Each NUFLI screen is 23KB. Even with shared backgrounds,
fitting multiple screens in 64KB RAM is tight.
**Solution:** Render with scanlines (every other line dark/black).
This creates a retro CRT aesthetic while reducing the effective pixel data.
**Approach:**
1. Pre-process source images: darken every other scanline before conversion
2. The scanlined image has ~50% dark pixels → NUFLI bitmap has more uniform blocks
3. Mufflon's conversion produces more shared data between screens
4. Visual quality: retro CRT look, acceptable for static screens
**Implementation:**
- `tools/apply_scanlines.py` pre-processes PNGs before Mufflon conversion
- `make nufli` pipeline: PNG → scanlined BMP → Mufflon → .nuf → delta encoding
### Delta Encoding
**Problem:** Even with scanlines, storing all NUFLI screens in 64KB RAM is tight.
**Solution:** Delta encoding with bitmask format:
- Store a shared base (consensus across all screens) once
- Store per-screen deltas as bitmask + differing values
**Format:**
- Base: 23040 bytes (consensus data)
- Delta: 2880 bytes bitmask + N bytes values
- Each set bit in bitmask = byte differs from base
**Results (with scanlines):**
- Raw: 5 × 23040 = 115200 bytes
- Encoded: 23040 (base) + 45969 (deltas) = 69009 bytes
- Savings: 40.1%
**Memory layout:**
- Base (23KB) at $1000-$7FFF (NUFLI display region)
- Title delta (7KB) at $A000-$BFFF (BASIC ROM area)
- Other deltas: TODO (disk loading or art direction to reduce size)
### Implementation Steps
#### Step 1: NUFLI display routine integration
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env python3
"""Apply scanline effect: darken every odd row for CRT aesthetic."""
import sys
from PIL import Image
src, dst = sys.argv[1], sys.argv[2]
img = Image.open(src).resize((320, 200), Image.LANCZOS).convert('RGB')
px = img.load()
for y in range(200):
if y % 2 == 1:
for x in range(320):
r, g, b = px[x, y]
px[x, y] = (r // 4, g // 4, b // 4)
img.save(dst)
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""NUFLI delta encoder — extract shared base + per-screen deltas.
Given multiple .nuf files, finds the consensus base (most common byte
at each position) and generates per-screen delta files.
Output format:
base.bin - consensus data (23040 bytes)
*.delta - per-screen patches using bitmask:
2880 bytes bitmask (1 bit per byte position)
N bytes of differing values (in order)
Usage:
python3 nufli_delta.py output_base screen1.nuf screen2.nuf ...
"""
import argparse
import sys
from pathlib import Path
from collections import Counter
NUFLI_SIZE = 23040
BITMASK_SIZE = (NUFLI_SIZE + 7) // 8 # 2880 bytes
def load_nuf(path):
"""Load .nuf file, skip 2-byte load address."""
data = Path(path).read_bytes()
if len(data) == NUFLI_SIZE + 2:
return data[2:]
elif len(data) == NUFLI_SIZE:
return data
else:
print(f"Warning: {path} is {len(data)} bytes, expected {NUFLI_SIZE} or {NUFLI_SIZE + 2}")
return data[:NUFLI_SIZE]
def find_base(screens):
"""Find consensus base: most common byte at each position."""
base = bytearray(NUFLI_SIZE)
for i in range(NUFLI_SIZE):
counts = Counter()
for s in screens:
counts[s[i]] += 1
base[i] = counts.most_common(1)[0][0]
return bytes(base)
def compute_delta_bitmask(base, screen):
"""Compute delta between base and screen using bitmask format.
Returns (bitmask, values) where bitmask indicates which bytes differ."""
bitmask = bytearray(BITMASK_SIZE)
values = bytearray()
for i in range(NUFLI_SIZE):
if base[i] != screen[i]:
byte_idx = i // 8
bit_idx = i % 8
bitmask[byte_idx] |= (1 << bit_idx)
values.append(screen[i])
return bytes(bitmask), bytes(values)
def main():
ap = argparse.ArgumentParser(description="NUFLI delta encoder")
ap.add_argument("output_base", help="Output base path (no extension)")
ap.add_argument("inputs", nargs="+", help="Input .nuf files")
args = ap.parse_args()
if len(args.inputs) < 2:
print("Error: need at least 2 input files for delta encoding")
sys.exit(1)
# Load all screens
screens = []
names = []
for path in args.inputs:
screens.append(load_nuf(path))
names.append(Path(path).stem)
# Find consensus base
base = find_base(screens)
out_path = Path(args.output_base)
out_path.parent.mkdir(parents=True, exist_ok=True)
# Write base
base_file = out_path.with_suffix(".base")
base_file.write_bytes(base)
print(f"Base: {base_file} ({len(base)} bytes)")
# Compute and write deltas
total_delta_bytes = 0
for name, screen in zip(names, screens):
bitmask, values = compute_delta_bitmask(base, screen)
delta_data = bitmask + values
delta_file = out_path.parent / f"{name}.delta"
delta_file.write_bytes(delta_data)
total_delta_bytes += len(delta_data)
print(f" {name}.delta: {len(values)} patches, {len(delta_data)} bytes "
f"(bitmask={len(bitmask)} + values={len(values)})")
# Summary
total_raw = len(screens) * NUFLI_SIZE
total_encoded = len(base) + total_delta_bytes
print(f"\nTotal raw: {total_raw} bytes")
print(f"Total encoded: {total_encoded} bytes (base={len(base)} + deltas={total_delta_bytes})")
print(f"Savings: {total_raw - total_encoded} bytes ({100 * (1 - total_encoded / total_raw):.1f}%)")
if __name__ == "__main__":
main()