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:
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user