diff --git a/Makefile b/Makefile index aba24d0..13d31fb 100644 --- a/Makefile +++ b/Makefile @@ -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)" diff --git a/src/data/nufli/nufli_base.base b/src/data/nufli/nufli_base.base new file mode 100644 index 0000000..7fa2d69 Binary files /dev/null and b/src/data/nufli/nufli_base.base differ diff --git a/src/data/nufli/screen_title.delta b/src/data/nufli/screen_title.delta new file mode 100644 index 0000000..6abb703 Binary files /dev/null and b/src/data/nufli/screen_title.delta differ diff --git a/src/data/nufli/screen_title.h b/src/data/nufli/screen_title.h deleted file mode 100644 index f9d2f2c..0000000 --- a/src/data/nufli/screen_title.h +++ /dev/null @@ -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 diff --git a/src/data/nufli/screen_waiting1.delta b/src/data/nufli/screen_waiting1.delta new file mode 100644 index 0000000..f752a2d Binary files /dev/null and b/src/data/nufli/screen_waiting1.delta differ diff --git a/src/data/nufli/screen_waiting1.h b/src/data/nufli/screen_waiting1.h deleted file mode 100644 index a74d486..0000000 --- a/src/data/nufli/screen_waiting1.h +++ /dev/null @@ -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 diff --git a/src/data/nufli/screen_waiting_2.delta b/src/data/nufli/screen_waiting_2.delta new file mode 100644 index 0000000..e5139d2 Binary files /dev/null and b/src/data/nufli/screen_waiting_2.delta differ diff --git a/src/data/nufli/screen_waiting_2.h b/src/data/nufli/screen_waiting_2.h deleted file mode 100644 index 9c221a7..0000000 --- a/src/data/nufli/screen_waiting_2.h +++ /dev/null @@ -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 diff --git a/src/data/nufli/screen_win_hare.delta b/src/data/nufli/screen_win_hare.delta new file mode 100644 index 0000000..29f8d49 Binary files /dev/null and b/src/data/nufli/screen_win_hare.delta differ diff --git a/src/data/nufli/screen_win_hare.h b/src/data/nufli/screen_win_hare.h deleted file mode 100644 index 906d96b..0000000 --- a/src/data/nufli/screen_win_hare.h +++ /dev/null @@ -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 diff --git a/src/data/nufli/screen_win_scoot.delta b/src/data/nufli/screen_win_scoot.delta new file mode 100644 index 0000000..b708859 Binary files /dev/null and b/src/data/nufli/screen_win_scoot.delta differ diff --git a/src/data/nufli/screen_win_scoot.h b/src/data/nufli/screen_win_scoot.h deleted file mode 100644 index 0e8daba..0000000 --- a/src/data/nufli/screen_win_scoot.h +++ /dev/null @@ -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 diff --git a/src/game.c b/src/game.c index 431ff8f..8f6855b 100644 --- a/src/game.c +++ b/src/game.c @@ -28,6 +28,7 @@ #include "score.h" #include "banner.h" #include "draw.h" +#include "nufli.h" #include #include @@ -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); } diff --git a/src/main.c b/src/main.c index 429fbb2..515624d 100644 --- a/src/main.c +++ b/src/main.c @@ -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 (;;) ; diff --git a/src/nufli.c b/src/nufli.c new file mode 100644 index 0000000..c087ec5 --- /dev/null +++ b/src/nufli.c @@ -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 +#include +#include + +// 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; +} diff --git a/src/nufli.h b/src/nufli.h new file mode 100644 index 0000000..686864d --- /dev/null +++ b/src/nufli.h @@ -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 diff --git a/src/test_nufli.c b/src/test_nufli.c new file mode 100644 index 0000000..92fe0db --- /dev/null +++ b/src/test_nufli.c @@ -0,0 +1,17 @@ +#include "memmap.h" +#include "nufli.h" +#include + +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; +} diff --git a/src/test_nufli2.c b/src/test_nufli2.c new file mode 100644 index 0000000..1533e4e --- /dev/null +++ b/src/test_nufli2.c @@ -0,0 +1,18 @@ +#include "memmap.h" +#include "nufli.h" +#include + +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; +} diff --git a/src/test_simple.c b/src/test_simple.c new file mode 100644 index 0000000..ff940e4 --- /dev/null +++ b/src/test_simple.c @@ -0,0 +1,16 @@ +#include "memmap.h" +#include + +int main(void) +{ + memmap_setup(); + + // Set border color to red + vic.color_border = 2; + + // Infinite loop + for (;;) + ; + + return 0; +} diff --git a/tasks.md b/tasks.md index 4b4cdfa..0893ded 100644 --- a/tasks.md +++ b/tasks.md @@ -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 diff --git a/tools/apply_scanlines.py b/tools/apply_scanlines.py new file mode 100644 index 0000000..a114afa --- /dev/null +++ b/tools/apply_scanlines.py @@ -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) diff --git a/tools/nufli_delta.py b/tools/nufli_delta.py new file mode 100644 index 0000000..ce728a3 --- /dev/null +++ b/tools/nufli_delta.py @@ -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()