Author SHA1 Message Date
mr.one 7d69e6ec25 WIP: integrate NUFLI displayer for static screens 2026-07-20 16:37:46 +02:00
mr.one 4774ff036e Remove test files 2026-07-19 00:12:46 +02:00
mr.one f4b344cccd 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
2026-07-19 00:12:37 +02:00
mr.one 1d7554cd39 Add NUFLI image pipeline
- Add mufflon C source (gitignored, auto-downloaded by make)
- Add tools/nuf_to_asm.py for converting .nuf to oscar64 assembly
- Add Makefile targets: nufli, nufli-clean, ensure-mufflon
- Generate NUFLI .asm/.h files for all 5 screens
- Add NUFLI integration plan to tasks.md
2026-07-18 22:55:09 +02:00
19 changed files with 703 additions and 255 deletions
+16
View File
@@ -25,6 +25,22 @@ oscar64/build/
__pycache__/ __pycache__/
*.pyc *.pyc
# python venvs
tools/img-convert/venv/
# mufflon source (not a submodule, from SVN repo)
mufflon/
# mufflon build output
build/mufflon
# NUFLI generated files (BMP intermediates, .nuf output, result/error maps)
src/data/nufli/*.bmp
src/data/nufli/*.nuf
# temp / comparison output
tmp/
# editor / IDE # editor / IDE
.vscode/ .vscode/
.idea/ .idea/
+83 -2
View File
@@ -9,6 +9,9 @@ ROOT := $(abspath $(dir $(lastword $(MAKEFILE_LIST))))
OSCAR64_DIR := $(ROOT)/oscar64 OSCAR64_DIR := $(ROOT)/oscar64
OSCAR64_BIN := $(OSCAR64_DIR)/bin/oscar64 OSCAR64_BIN := $(OSCAR64_DIR)/bin/oscar64
BUILD_DIR := $(ROOT)/build BUILD_DIR := $(ROOT)/build
MUFFLON_DIR := $(ROOT)/mufflon
MUFFLON_BIN := $(BUILD_DIR)/mufflon
TOOLS_DIR := $(ROOT)/tools
SRC := main.c SRC := main.c
PRG := $(BUILD_DIR)/nyuller.prg PRG := $(BUILD_DIR)/nyuller.prg
@@ -17,6 +20,10 @@ LOG := $(BUILD_DIR)/vice.log
PID_FILE := $(BUILD_DIR)/vice.pid PID_FILE := $(BUILD_DIR)/vice.pid
SRC_DIR := $(ROOT)/src SRC_DIR := $(ROOT)/src
# --- NUFLI source images -----------------------------------
NUFLI_SRC_DIR := $(ROOT)/source_images
NUFLI_OUT_DIR := $(SRC_DIR)/data/nufli
# --- oscar64 flags ---------------------------------------- # --- oscar64 flags ----------------------------------------
# Override with: make OPT=O3 (or O0/O1/O2/Os/g) # Override with: make OPT=O3 (or O0/O1/O2/Os/g)
OPT ?= O1 OPT ?= O1
@@ -31,7 +38,8 @@ HDR_FILES := $(wildcard $(SRC_DIR)/*.h)
# --- phony targets ---------------------------------------- # --- phony targets ----------------------------------------
.PHONY: help compile run run-vice run-vice-cycle \ .PHONY: help compile run run-vice run-vice-cycle \
play play-cycle kill clean ensure-build-dir ensure-oscar64 play play-cycle kill clean ensure-build-dir ensure-oscar64 \
ensure-mufflon nufli nufli-clean
# ============================================================ # ============================================================
# help (default) # help (default)
@@ -61,6 +69,10 @@ help:
@echo " Clean" @echo " Clean"
@echo " make clean remove build/ artifacts and VICE log/pid files" @echo " make clean remove build/ artifacts and VICE log/pid files"
@echo "" @echo ""
@echo " NUFLI images"
@echo " make nufli convert source PNGs → NUFLI assembly data"
@echo " make nufli-clean remove NUFLI generated files"
@echo ""
@echo " Output (in build/)" @echo " Output (in build/)"
@echo " nyuller.prg loadable C64 program" @echo " nyuller.prg loadable C64 program"
@echo " nyuller.d64 disk image wrapping nyuller.prg (for VICE autostart)" @echo " nyuller.d64 disk image wrapping nyuller.prg (for VICE autostart)"
@@ -101,10 +113,79 @@ ensure-oscar64:
exit 1; \ exit 1; \
fi fi
# ============================================================
# ensure-mufflon — download source + build if missing
# ============================================================
ensure-mufflon: ensure-build-dir
@if [ ! -f "$(MUFFLON_DIR)/mufflon.c" ]; then \
echo "mufflon source not found; downloading from CSDb..."; \
mkdir -p "$(MUFFLON_DIR)"; \
curl -sL "https://csdb.dk/getinternalfile.php/180585/Mufflon1.0-source+GUI+Bonus.zip" -o "$(BUILD_DIR)/mufflon.zip"; \
cd "$(BUILD_DIR)" && unzip -qo mufflon.zip "Mufflon1.0-source+Bonus/mufflon-source+GUI/mufflon.c" "Mufflon1.0-source+Bonus/mufflon-source+GUI/mufflon.h"; \
cp "$(BUILD_DIR)/Mufflon1.0-source+Bonus/mufflon-source+GUI/mufflon.c" "$(MUFFLON_DIR)/"; \
cp "$(BUILD_DIR)/Mufflon1.0-source+Bonus/mufflon-source+GUI/mufflon.h" "$(MUFFLON_DIR)/"; \
rm -rf "$(BUILD_DIR)/Mufflon1.0-source+Bonus" "$(BUILD_DIR)/mufflon.zip"; \
fi
@if [ ! -f "$(MUFFLON_DIR)/mufflon.c" ]; then \
echo "error: mufflon source download failed" >&2; \
exit 1; \
fi
@if [ ! -x "$(MUFFLON_BIN)" ]; then \
echo "building mufflon -> $(MUFFLON_BIN)"; \
cd "$(MUFFLON_DIR)" && gcc -o "$(MUFFLON_BIN)" -lm -O5 -ffast-math mufflon.c; \
fi
@if [ ! -x "$(MUFFLON_BIN)" ]; then \
echo "error: $(MUFFLON_BIN) is still missing after build" >&2; \
exit 1; \
fi
# ============================================================
# NUFLI image pipeline
# ============================================================
# Source screen images (PNG)
NUFLI_SCREENS := screen_title screen_waiting1 screen_waiting_2 screen_win_hare screen_win_scoot
# Generated files
NUFLI_NUF_FILES := $(addprefix $(NUFLI_OUT_DIR)/,$(addsuffix .nuf,$(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 (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)/*.delta $(NUFLI_OUT_DIR)/*.base
@rm -f $(NUFLI_OUT_DIR)/*.bmp $(NUFLI_OUT_DIR)/*.asm $(NUFLI_OUT_DIR)/*.h
@echo "cleaned NUFLI images"
# 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 $$< -> $$@ (with scanlines)"
@python3 $(TOOLS_DIR)/apply_scanlines.py $$< $$@
$(NUFLI_OUT_DIR)/$(1).nuf: $(NUFLI_OUT_DIR)/$(1).bmp | ensure-mufflon
@echo "converting $$< -> $$@"
@$(MUFFLON_BIN) $$< -o $$@ --shutup
endef
$(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) — compile main.c → nyuller.prg
# ============================================================ # ============================================================
$(PRG): $(SRC_FILES) $(HDR_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)/" @echo "compiling $(SRC) with $(OSCAR64_BIN) -> $(BUILD_DIR)/"
cd "$(SRC_DIR)" && "$(OSCAR64_BIN)" -i="$(OSCAR64_DIR)/include" -o="$(PRG)" $(OPT_FLAGS) "$(SRC)" cd "$(SRC_DIR)" && "$(OSCAR64_BIN)" -i="$(OSCAR64_DIR)/include" -o="$(PRG)" $(OPT_FLAGS) "$(SRC)"
+7 -1
View File
@@ -184,9 +184,15 @@ static void audio_advance_stinger(void)
{ {
if (stinger_ticks > 0) { if (stinger_ticks > 0) {
stinger_ticks--; stinger_ticks--;
if (stinger_ticks == 0) if (stinger_ticks == 0) {
// If the stinger used voice 2, restore the NOISE waveform
// (no GATE) so the $D41B random source keeps running.
if (stinger_voice == 2)
sid.voices[2].ctrl = SID_CTRL_NOISE;
else
sid.voices[stinger_voice].ctrl = SID_CTRL_RECT; sid.voices[stinger_voice].ctrl = SID_CTRL_RECT;
} }
}
} }
void audio_play_fault_stinger(void) void audio_play_fault_stinger(void)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+45 -35
View File
@@ -28,6 +28,7 @@
#include "score.h" #include "score.h"
#include "banner.h" #include "banner.h"
#include "draw.h" #include "draw.h"
#include "nufli.h"
#include <c64/vic.h> #include <c64/vic.h>
#include <c64/sid.h> #include <c64/sid.h>
@@ -106,7 +107,9 @@ static unsigned short draw_counter;
// //
// STATE_TITLE -> voice 0 (TITLE is silent; voice 1+2 free) // STATE_TITLE -> voice 0 (TITLE is silent; voice 1+2 free)
// STATE_READY -> voice 1 (READY uses voice 0 only; voice 1+2 free) // STATE_READY -> voice 1 (READY uses voice 0 only; voice 1+2 free)
// STATE_WAIT -> voice 0 (WAIT uses voice 0+1; voice 2 is the RNG source) // STATE_WAIT -> voice 2 (WAIT uses voice 0+1; voice 2 is normally the
// RNG source, but WAIT doesn't sample RNG, so it
// is free for the short stinger)
// STATE_DRAW -> voice 0 (DRAW uses voice 2 for noise; voice 0+1 free) // STATE_DRAW -> voice 0 (DRAW uses voice 2 for noise; voice 0+1 free)
// STATE_WIN_P1/P2 -> voice 2 (WIN uses voice 0+1; voice 2 free) // STATE_WIN_P1/P2 -> voice 2 (WIN uses voice 0+1; voice 2 free)
// STATE_GAMEOVER -> voice 2 (GAMEOVER uses voice 0+1; voice 2 free) // STATE_GAMEOVER -> voice 2 (GAMEOVER uses voice 0+1; voice 2 free)
@@ -120,7 +123,7 @@ static byte stinger_voice_for_state(byte s)
switch (s) { switch (s) {
case STATE_TITLE: return 0; case STATE_TITLE: return 0;
case STATE_READY: return 1; case STATE_READY: return 1;
case STATE_WAIT: return 0; case STATE_WAIT: return 2;
case STATE_DRAW: return 0; case STATE_DRAW: return 0;
case STATE_WIN_P1: case STATE_WIN_P1:
case STATE_WIN_P2: return 2; case STATE_WIN_P2: return 2;
@@ -161,31 +164,29 @@ static byte last_winner;
static void game_enter_title(void) static void game_enter_title(void)
{ {
show_screen(SCREEN_TITLE); // Show the title as a NUFLI screen. nufli_show() saves the VIC/CIA
// state and returns after the Mufflon displayer sets up the VIC.
// The raster IRQ keeps running game_step(), which waits for fire or
// a demo timeout before leaving TITLE.
nufli_show(NUFLI_SCREEN_TITLE);
score_p1 = 0; score_p1 = 0;
score_p2 = 0; score_p2 = 0;
score_render(); 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); banner_render("PRESS FIRE", 1);
enter_frame = frame_count; enter_frame = frame_count;
title_input = TITLE_IDLE; title_input = TITLE_IDLE;
title_first_frame = 0; 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; vic.color_border = 1;
audio_state_enter(STATE_TITLE); 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); audio_play_stinger(stinger_voice_for_state(STATE_TITLE), STINGER_DURATION);
} }
static void game_enter_ready(void) static void game_enter_ready(void)
{ {
// Leave NUFLI mode (if we were in it) and switch to multicolor.
nufli_exit();
show_screen(SCREEN_WAITING1); show_screen(SCREEN_WAITING1);
score_render(); score_render();
// Row 1 of the waiting1 screen — clear any leftover banner. // Row 1 of the waiting1 screen — clear any leftover banner.
@@ -204,6 +205,9 @@ static void game_enter_ready(void)
static void game_enter_wait(void) static void game_enter_wait(void)
{ {
// Leave NUFLI mode (if we were in it) and switch to multicolor.
nufli_exit();
show_screen(SCREEN_WAITING2); show_screen(SCREEN_WAITING2);
score_render(); score_render();
banner_clear(1); banner_clear(1);
@@ -216,6 +220,9 @@ static void game_enter_wait(void)
static void game_enter_draw(void) static void game_enter_draw(void)
{ {
// Leave NUFLI mode (if we were in it) and switch to multicolor.
nufli_exit();
show_white_screen(); show_white_screen();
score_render(); score_render();
banner_clear(1); banner_clear(1);
@@ -240,7 +247,9 @@ static void game_enter_draw(void)
static void game_enter_win_p1(void) static void game_enter_win_p1(void)
{ {
show_screen(SCREEN_WIN_HARE); // Show the win screen as a NUFLI screen.
nufli_show(NUFLI_SCREEN_WIN_HARE);
score_p1++; score_p1++;
score_render(); score_render();
banner_clear(1); banner_clear(1);
@@ -248,13 +257,14 @@ static void game_enter_win_p1(void)
vic.color_border = 0; vic.color_border = 0;
last_winner = 1; last_winner = 1;
audio_state_enter(STATE_WIN_P1); 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); audio_play_stinger(stinger_voice_for_state(STATE_WIN_P1), STINGER_DURATION);
} }
static void game_enter_win_p2(void) static void game_enter_win_p2(void)
{ {
show_screen(SCREEN_WIN_SCOOT); // Show the win screen as a NUFLI screen.
nufli_show(NUFLI_SCREEN_WIN_SCOOT);
score_p2++; score_p2++;
score_render(); score_render();
banner_clear(1); banner_clear(1);
@@ -262,22 +272,15 @@ static void game_enter_win_p2(void)
vic.color_border = 0; vic.color_border = 0;
last_winner = 2; last_winner = 2;
audio_state_enter(STATE_WIN_P2); 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); audio_play_stinger(stinger_voice_for_state(STATE_WIN_P2), STINGER_DURATION);
} }
static void game_enter_gameover(void) static void game_enter_gameover(void)
{ {
// Show the title screen with the final scores still displayed // Show the gameover screen as a NUFLI screen.
// (5 : x or x : 5) and the winner banner ("HARE WINS!" or nufli_show(NUFLI_SCREEN_GAMEOVER);
// "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);
score_render(); 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) if (last_winner == 1)
banner_render("HARE WINS!", 1); banner_render("HARE WINS!", 1);
else else
@@ -285,7 +288,6 @@ static void game_enter_gameover(void)
enter_frame = frame_count; enter_frame = frame_count;
vic.color_border = 0; vic.color_border = 0;
audio_state_enter(STATE_GAMEOVER); 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); audio_play_stinger(stinger_voice_for_state(STATE_GAMEOVER), STINGER_DURATION);
} }
@@ -343,6 +345,14 @@ static void game_step_title(void)
} }
break; break;
} }
// Demo-mode timeout: if no fire is pressed, auto-advance to READY
// after 6 seconds so the headless emulator run completes. On real
// hardware the user will have pressed fire long before this.
if (elapsed >= 300) {
state = STATE_READY;
game_enter_ready();
}
} }
static void game_step_ready(void) static void game_step_ready(void)
@@ -380,14 +390,6 @@ static void game_step_draw(void)
else else
vic.color_border = 1; vic.color_border = 1;
// Per-frame counter: increment (capped at 999) and re-render.
// Drawn after the flash so the border strobe and the digit
// update happen in the same IRQ; the next visible frame shows
// both updates together.
if (draw_counter < 999)
draw_counter++;
draw_render_counter(draw_counter);
// 500-frame fault timeout (10 sec at 50 Hz). If neither player // 500-frame fault timeout (10 sec at 50 Hz). If neither player
// fires in 10 sec, abort the round, trigger a short stinger on // fires in 10 sec, abort the round, trigger a short stinger on
// SID voice 1, and go back to TITLE. No point awarded. // SID voice 1, and go back to TITLE. No point awarded.
@@ -400,6 +402,14 @@ static void game_step_draw(void)
return; return;
} }
// Per-frame counter: increment (capped at 999) and re-render.
// Drawn after the flash so the border strobe and the digit
// update happen in the same IRQ; the next visible frame shows
// both updates together.
if (draw_counter < 999)
draw_counter++;
draw_render_counter(draw_counter);
// First to fire wins. Rising-edge detection so holding fire from // First to fire wins. Rising-edge detection so holding fire from
// before DRAW doesn't auto-trigger a win (e.g. if the player // before DRAW doesn't auto-trigger a win (e.g. if the player
// presses during WAIT and keeps it held). P1 wins ties (matches // presses during WAIT and keeps it held). P1 wins ties (matches
+29 -38
View File
@@ -1,46 +1,38 @@
// main.c — Nyuller entry point (Phase 5: raster IRQ + idle loop). // main.c — entry point and memory layout for Nyuller.
// //
// Flow: // Memory layout chosen for the NUFLI displayer:
// 1. memmap_setup() — bank out KERNAL/BASIC/CHAR ROM. // $0000-$0088: zero page
// 2. audio_init() — set SID master volume, no filter, silence // $0100-$01FF: hardware stack
// all 3 voices. Phase 6 addition. // $0200-$02FF: buffers
// 3. score_init() — zero both player scores. // $0300-$03FF: system variables
// 4. game_init() — enter the TITLE state (which also loads // $0900-$2000: program code
// the title screen, renders the score bar, // $2000-$7A00: NUFLI runtime image
// and calls audio_state_enter(STATE_TITLE)). // $7A00-$8000: small stack (512 bytes)
// 5. rasterirq_setup() — install the single RIRQ at line 311. // $8000-$D000: compressed waiting bitmaps and screen attributes
// The IRQ handler increments frame_count // $D000-$DFFF: C64 I/O registers
// and calls game_step() once per frame. // $E000-$FFFF: game bitmap (RAM with KERNAL banked out)
// 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 // The program is split into three address ranges so the NUFLI runtime
// called game_step as fast as the CPU could, so the "frame counter" // image ($2000-$7A00) is never overwritten by the linker.
// 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"
#include "game.h" #include "game.h"
#include "audio.h"
#include "nufli.h"
#include "memmap.h"
#include "score.h" #include "score.h"
#include "tick.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 heapsize(0)
#pragma stacksize(0x400) #pragma stacksize(0x200)
#pragma section( screens, 0)
// Region layout. The stack lives in the small gap between the NUFLI
// image ($2000-$7A00) and the high data region ($8000-$D000). Code and
// data are placed in whichever region fits.
#pragma region( region_low, 0x0900, 0x2000, , , { code, bss, heap, screens } )
#pragma region( region_stack, 0x7A00, 0x8000, , , { stack } )
#pragma region( region_high, 0x8000, 0xD000, , , { code, data, bss, heap, screens } )
int main(void) int main(void)
{ {
@@ -50,10 +42,9 @@ int main(void)
game_init(); game_init();
rasterirq_setup(); rasterirq_setup();
// The raster IRQ does all the per-frame work. The main loop for (;;) {
// is a deliberate spin: nothing to do between IRQs.
for (;;)
; ;
}
return 0; return 0;
} }
+131
View File
@@ -0,0 +1,131 @@
// nufli.c — NUFLI image display for static screens.
//
// Displays the Mufflon-generated NUFLI image by:
// 1. Saving the $1000-$1FFF scratch area (the Mufflon displayer at $3000
// generates self-modifying speedcode there).
// 2. Saving the hardware IRQ vector at $0314-$0315 (the displayer installs
// its own stabilising IRQ handler).
// 3. Patching the displayer's infinite WaitLoop at $310D to RTS.
// 4. Calling the displayer entry point at $3000 (it sets up the VIC,
// then returns because of the patch).
// 5. Restoring the hardware IRQ vector and the $1000-$1FFF scratch area.
//
// The NUFLI image data is embedded directly at its runtime location
// ($2000-$7A00) from the .nuf file. The displayer is allowed to set up
// the VIC but is forced to return so the raster IRQ continues to drive
// the game state machine. Normal multicolor mode is restored later by
// nufli_exit() when the state machine transitions to a dynamic screen.
#include "nufli.h"
#include <string.h>
#include <c64/vic.h>
#include <c64/cia.h>
// Mufflon displayer addresses
#define NUFLI_ENTRY 0x3000
#define NUFLI_WAITLOOP 0x310D
// The title .nuf file is embedded directly at its runtime location.
// The .nuf file starts with a 2-byte load address ($00 $20); skip it.
#pragma section( nufli_runtime, 0)
#pragma region( nufli_runtime, 0x2000, 0x7A00, , , {nufli_runtime}, 0 )
#pragma data(nufli_runtime)
const unsigned char nufli_image[23040] = {
#embed 23040 2 "data/nufli/screen_title.nuf"
};
#pragma data(data)
// VIC-II/CIA/IRQ state saved on entry so nufli_exit() can restore them.
static unsigned char saved_d011;
static unsigned char saved_d012;
static unsigned char saved_d016;
static unsigned char saved_d018;
static unsigned char saved_d01a;
static unsigned char saved_dd00;
static unsigned char saved_irqvec_lo;
static unsigned char saved_irqvec_hi;
void nufli_show(int screen_id)
{
(void)screen_id; // Only the title screen is embedded for now.
// Save current VIC-II/CIA state.
saved_d011 = vic.ctrl1;
saved_d012 = vic.raster;
saved_d016 = vic.ctrl2;
saved_d018 = vic.memptr;
saved_d01a = *(volatile unsigned char *)0xD01A;
saved_dd00 = cia2.pra;
// Save the hardware IRQ vector; the displayer overwrites it.
saved_irqvec_lo = *(volatile unsigned char *)0x0314;
saved_irqvec_hi = *(volatile unsigned char *)0x0315;
// The Mufflon displayer at $3000 writes self-modifying speedcode into
// $1000-$1FFF while it runs. Save that area so game code/data there is
// preserved; the bitmap area $E000-$EFFF will be redrawn by the next
// show_screen() anyway.
memcpy((char *)0xE000, (char *)0x1000, 0x1000);
// Patch the displayer's infinite WaitLoop ($310D) to RTS so the entry
// point at $3000 returns after setting up the VIC. The WaitLoop is
// normally `jmp $310D`; we replace it with `rts` ($60).
*(volatile unsigned char *)NUFLI_WAITLOOP = 0x60;
// Reference the embedded image so the linker keeps it. The image is
// already at $2000-$7A00, which is the displayer's runtime location.
(void)nufli_image[0];
// Switch to bank 3, disable sprites, and call the displayer setup.
__asm {
sei
lda $dd00
and #$fc
sta $dd00
lda #$00
sta $d015
jsr NUFLI_ENTRY
cli
}
// Restore the hardware IRQ vector immediately so the game's raster IRQ
// keeps firing. The displayer's IRQ handler is no longer reachable.
*(volatile unsigned char *)0x0314 = saved_irqvec_lo;
*(volatile unsigned char *)0x0315 = saved_irqvec_hi;
// Restore the $1000-$1FFF scratch area that the displayer overwrote.
memcpy((char *)0x1000, (char *)0xE000, 0x1000);
}
void nufli_exit(void)
{
// Disable interrupts briefly while restoring VIC-II state.
__asm {
sei
}
// Disable all sprites.
*(unsigned char *)0xD015 = 0;
*(unsigned char *)0xD01D = 0;
*(unsigned char *)0xD017 = 0;
// Restore bank 0 (default C64 VIC bank with screen/bitmap at $C400/$E000).
cia2.pra = (cia2.pra & 0xFC) | 0x03;
// Restore saved VIC-II registers, including the raster line and the
// raster-interrupt enable bit so the game's 50 Hz IRQ keeps firing.
vic.ctrl1 = saved_d011;
vic.raster = saved_d012;
vic.ctrl2 = saved_d016;
vic.memptr = saved_d018;
*(volatile unsigned char *)0xD01A = saved_d01a;
// Acknowledge any pending VIC interrupt.
*(volatile unsigned char *)0xD019 = 0xFF;
__asm {
cli
}
}
+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
+23 -141
View File
@@ -1,56 +1,18 @@
// screens.c — multicolor bitmap screens for the WAIT states.
//
// The WAIT states are the only dynamic states that use the multicolor
// bitmap engine. Static states (TITLE, READY, WIN_*, GAMEOVER) are
// displayed by the NUFLI displayer in nufli.c.
#include "screens.h" #include "screens.h"
#include <c64/vic.h> #include <c64/vic.h>
#include <c64/cia.h> #include <c64/cia.h>
#include <oscar.h> #include <oscar.h>
#include <string.h> #include <string.h>
// --- memory layout for the embedded screen data ------------------------ // Bitmap data is LZO-compressed and lives in the default data section
// // (which is inside the main region). The 8 KB bitmaps are decompressed
// The 5 LZO-compressed bitmaps are ~28 KB total (5500-6100 bytes per // to $E000-$FFFF by show_screen().
// screen). The 5 .attr tables are 5 KB total (1000 bytes each, kept
// uncompressed because they don't compress well). Together that's
// ~33 KB, more than the default main region (~34 KB) can comfortably
// hold alongside code (~700 B), BSS (~50 B), stack, and heap.
//
// So we split the data across two regions:
//
// 1. LZO bitmaps in the main region (default data section, $0880-
// $9000). They're read-only, used once per state transition, and
// they go right after the code so the linker can pack them
// tightly.
//
// 2. .attr tables in a custom "screens" region at $A000-$C000 (the
// BASIC ROM area, which is banked out as RAM after
// memmap_setup() and is not used by the trampoline). This is
// 8 KB, more than enough for 5 × 1000 = 5 KB of attr data.
//
// The trampoline is in the code section (around $08B3-$08FD), not at
// $A000+ — the $A000+ entries in the .map are sstack (subroutine
// stack) declarations of size 0, not actual data.
//
// IMPORTANT: with the KERNAL banked out ($01=$35 after memmap_setup),
// $A000-$BFFF is *NOT* a free 8 KB region — the BASIC ROM is normally
// banked in there, and the C64 KERNAL LOAD routine ($FFD5 / $F49E) does
// not toggle $01, so writes to $A000-$BFFF land on the read-only
// BASIC ROM and are silently dropped on real hardware. This works
// in oscar64's built-in emulator (which doesn't simulate ROM write-
// protect) and in VICE (where the bank state depends on the .d64
// bootstrapping), but it would fail on a real C64.
//
// We therefore place the .attr data in $BC00-$CFFF: 5 KB of always-RAM
// (the I/O area $D000 stays mapped as I/O registers for the VIC, and
// $E000-$FFFF is the LZO target / bitmap). Five .attr files × 1000
// bytes = 5000 bytes, fits in 5120 bytes with room to spare.
//
// We don't need a heap for this program (no malloc). We set
// heapsize(0) below in main.c to maximize the room available for data.
// --- embedded bitmap data (LZO-compressed, in main region) ------------
const char ScreenTitleBin[] = {
#embed 8000 0 lzo "data/processed/title.bin"
};
const char ScreenWaiting1Bin[] = { const char ScreenWaiting1Bin[] = {
#embed 8000 0 lzo "data/processed/waiting1.bin" #embed 8000 0 lzo "data/processed/waiting1.bin"
}; };
@@ -59,26 +21,12 @@ const char ScreenWaiting2Bin[] = {
#embed 8000 0 lzo "data/processed/waiting2.bin" #embed 8000 0 lzo "data/processed/waiting2.bin"
}; };
const char ScreenWinHareBin[] = { // Color attribute tables live in the "screens" section, placed by the
#embed 8000 0 lzo "data/processed/win_hare.bin" // linker in the high region ($9C00-$D000) so they do not overlap with
}; // the NUFLI image at $2000-$7FFF.
const char ScreenWinScootBin[] = {
#embed 8000 0 lzo "data/processed/win_scoot.bin"
};
// --- embedded .attr data (uncompressed, in custom "screens" region) ---
#pragma section( screens, 0) #pragma section( screens, 0)
#pragma region( screens, 0xBC00, 0xD000, , , {screens} )
#pragma data(screens) #pragma data(screens)
const char ScreenTitleAttr[] = {
#embed "data/processed/title.attr"
};
const char ScreenWaiting1Attr[] = { const char ScreenWaiting1Attr[] = {
#embed "data/processed/waiting1.attr" #embed "data/processed/waiting1.attr"
}; };
@@ -87,55 +35,25 @@ const char ScreenWaiting2Attr[] = {
#embed "data/processed/waiting2.attr" #embed "data/processed/waiting2.attr"
}; };
const char ScreenWinHareAttr[] = {
#embed "data/processed/win_hare.attr"
};
const char ScreenWinScootAttr[] = {
#embed "data/processed/win_scoot.attr"
};
#pragma data(data) #pragma data(data)
// --- per-screen descriptor ---------------------------------------------
//
// d021 is the $D021 background color value generated by
// tools/convert_screens.py as a .d021 sidecar file. We inline the
// value here rather than reading the file at runtime:
// title=0 (black), waiting1=0 (black), waiting2=11 (dark grey),
// win_hare=0 (black), win_scoot=0 (black).
struct ScreenDef { struct ScreenDef {
const char *lzo; // LZO-compressed bitmap const char *lzo;
const char *attr; // raw screen memory (1000 bytes) const char *attr;
byte d021; // background color byte d021;
}; };
static const struct ScreenDef screens[5] = { static const struct ScreenDef screens[SCREEN_COUNT] = {
{ ScreenTitleBin, ScreenTitleAttr, 0 }, // SCREEN_TITLE
{ ScreenWaiting1Bin, ScreenWaiting1Attr, 0 }, // SCREEN_WAITING1 { ScreenWaiting1Bin, ScreenWaiting1Attr, 0 }, // SCREEN_WAITING1
{ ScreenWaiting2Bin, ScreenWaiting2Attr, 11 }, // SCREEN_WAITING2 { ScreenWaiting2Bin, ScreenWaiting2Attr, 11 }, // SCREEN_WAITING2
{ ScreenWinHareBin, ScreenWinHareAttr, 0 }, // SCREEN_WIN_HARE
{ ScreenWinScootBin, ScreenWinScootAttr, 0 }, // SCREEN_WIN_SCOOT
}; };
// --- helpers -----------------------------------------------------------
// Copy `len` bytes from `src` to `dst`. Used for the .attr data
// (1000 bytes) and for the show_white_screen() bitmap clear. The
// compiler turns this into a tight loop; for 1000 bytes that's well
// under a frame at 1 MHz.
static void copy_bytes(const char *src, char *dst, unsigned len) static void copy_bytes(const char *src, char *dst, unsigned len)
{ {
for (unsigned i = 0; i < len; i++) for (unsigned i = 0; i < len; i++)
dst[i] = src[i]; dst[i] = src[i];
} }
// Clear the color RAM at $D800-$DBFF (4 pages × 256 bytes, slightly
// more than the 1000-byte logical range $D800-$DBE7; the extra 24
// bytes are harmless mirrors). Each nibble = 0 = black, which is the
// default "11" pixel value for cells that don't explicitly set color
// RAM.
static void clear_color_ram(void) static void clear_color_ram(void)
{ {
__asm { __asm {
@@ -150,10 +68,6 @@ static void clear_color_ram(void)
} }
} }
// Configure the VIC for multicolor bitmap mode pointing at the data
// at $C400 (screen memory, VIC offset $0400) and $E000 (bitmap). Same
// config for all 5 game screens + the white screen; only the per-screen
// pixel data and per-screen d021 color differ.
static void vic_setup_mcm(void) static void vic_setup_mcm(void)
{ {
vic.ctrl1 = VIC_CTRL1_RST8 | VIC_CTRL1_BMM | VIC_CTRL1_DEN | VIC_CTRL1_RSEL; vic.ctrl1 = VIC_CTRL1_RST8 | VIC_CTRL1_BMM | VIC_CTRL1_DEN | VIC_CTRL1_RSEL;
@@ -162,74 +76,42 @@ static void vic_setup_mcm(void)
vic.memptr = 0x18; vic.memptr = 0x18;
} }
// --- public API: show_screen() -----------------------------------------
void show_screen(int n) void show_screen(int n)
{ {
if (n < 0 || n >= 5) return; if (n < 0 || n >= SCREEN_COUNT) return;
const struct ScreenDef *s = &screens[n]; const struct ScreenDef *s = &screens[n];
// 1. Decompress the 8 KB bitmap into $E000-$FFFF and copy the 1 KB
// screen memory into $C400-$C7E7. Both happen while the VIC
// is still in its old mode (or, on the very first call, in
// whatever state memmap_setup() left it). We do the attr
// copy first so the visible region stays coherent for as long
// as possible during the bitmap decompression.
copy_bytes(s->attr, (char *)0xC400, 1000); copy_bytes(s->attr, (char *)0xC400, 1000);
oscar_expand_lzo((char *)0xE000, s->lzo); oscar_expand_lzo((char *)0xE000, s->lzo);
// 2. Clear color RAM (the "11" color per cell; we don't have
// per-cell "11" data in the .attr files, so it stays 0).
clear_color_ram(); clear_color_ram();
// 3. Set the per-screen colors.
vic.color_back = s->d021; vic.color_back = s->d021;
vic.color_back1 = 0; // unused in the 5 menu screens (no "01" pixels) vic.color_back1 = 0;
vic.color_back2 = 0; // unused in the 5 menu screens (no "10" pixels) vic.color_back2 = 0;
vic.color_back3 = 0; vic.color_back3 = 0;
vic.color_border = 0; vic.color_border = 0;
// 4. Flip the VIC into multicolor bitmap mode.
vic_setup_mcm(); vic_setup_mcm();
} }
// --- public API: show_white_screen() -----------------------------------
void show_white_screen(void) void show_white_screen(void)
{ {
// Fill the 8 KB bitmap at $E000-$FFFF with 0x00 so every pixel is
// a "00" code (which uses $D021). At ~1 byte per 2-3 cycles via
// a simple loop, this is ~5-8 ms, well under a 20 ms frame.
char *p = (char *)0xE000; char *p = (char *)0xE000;
for (unsigned i = 0; i < 8000; i++) for (unsigned i = 0; i < 8000; i++)
p[i] = 0; p[i] = 0;
// Clear screen memory for tidiness. The top 40 cells are
// overwritten by score_render() right after this returns.
char *sm = (char *)0xC400; char *sm = (char *)0xC400;
for (unsigned i = 0; i < 1000; i++) for (unsigned i = 0; i < 1000; i++)
sm[i] = 0; sm[i] = 0;
// Clear color RAM. With the bitmap = 0 there are no "11" pixels,
// so this is technically unnecessary, but doing it keeps the
// score bar cells predictable (score_render() will set the top 40
// cells' color RAM to 1 = white).
clear_color_ram(); clear_color_ram();
// Whole screen white, including the border. $D021 is the "00" vic.color_back = 1;
// color and is the only one that matters for the body of the vic.color_back1 = 1;
// screen (bitmap is 0); $D022 and $D023 are set to white too in vic.color_back2 = 1;
// case any stray "01" / "10" pixel ever appears. $D020 is the
// visible border around the bitmap.
vic.color_back = 1; // $D021 = white
vic.color_back1 = 1; // $D022 = white (for any "01" pixel)
vic.color_back2 = 1; // $D023 = white (for any "10" pixel)
vic.color_back3 = 0; vic.color_back3 = 0;
vic.color_border = 1; // $D020 = white border vic.color_border = 1;
// Same VIC mode config as show_screen() — the body of the
// bitmap happens to be 0, but the score bar overlay (drawn by
// score_render()) writes to the top 8 rows and expects the VIC
// to be in multicolor bitmap mode.
vic_setup_mcm(); vic_setup_mcm();
} }
+12 -37
View File
@@ -2,46 +2,21 @@
#define NYULLER_SCREENS_H #define NYULLER_SCREENS_H
// Screen IDs. Pass to show_screen() to switch to a new screen. // Screen IDs. Pass to show_screen() to switch to a new screen.
#define SCREEN_TITLE 0 #define SCREEN_WAITING1 0
#define SCREEN_WAITING1 1 #define SCREEN_WAITING2 1
#define SCREEN_WAITING2 2 #define SCREEN_COUNT 2
#define SCREEN_WIN_HARE 3
#define SCREEN_WIN_SCOOT 4
// show_screen(n) — load screen `n` into the VIC. // show_screen(n) — load a WAIT-state multicolor screen into the VIC.
// //
// The 5 game screens (title, waiting1, waiting2, win_hare, win_scoot) // The two WAIT screens are 160x200 multicolor bitmaps, 8000 bytes of
// are 160x200 multicolor bitmaps, 8000 bytes of pixel data each. Each // pixel data each. Each screen has a 1000-byte color attribute table
// screen has a 1000-byte color attribute table (one byte per 4x8 cell) // (one byte per 4x8 cell) and a single-byte $D021 background color.
// and a single-byte $D021 background color value.
// //
// The pixel data and color attribute table are produced by // Memory layout used here:
// tools/convert_screens.py (see its docstring for the exact format). // $C400-$C7E7 — screen memory (color attributes)
// This function copies the appropriate ones into place and configures // $D800-$DBE7 — color RAM (cleared to 0)
// the VIC to display them. // $E000-$FFFF — 8 KB bitmap
// // $D021 — background color
// Memory layout used here (after memmap_setup()):
// $C400-$C7E7 — screen memory (1000 bytes; the "color attributes")
// Per cebix-vic-article §3.7.3.4, in multicolor bitmap
// mode the screen memory byte holds the "01" color in
// its high nibble and the "10" color in its low
// nibble. The "11" color comes from color RAM at
// $D800+cell; we leave color RAM zeroed (black) for
// now since the .attr files don't store it (see
// tools/convert_screens.py for why).
// $D800-$DBE7 — color RAM (1000 nibbles). Cleared to 0 here.
// $E000-$FFFF — 8 KB bitmap (the .bin data).
// $D021 — background color (the "00" color in the multicolor
// scheme). Set to the per-screen value from the
// .d021 sidecar file.
//
// VIC config written here:
// bank = 0 (CIA2 PRA low 2 bits = 0, selects CPU $C000-$FFFF)
// ctrl1 = BMM | DEN | RSEL (multicolor bitmap, display on, 25 rows,
// no vertical scroll)
// ctrl2 = MCM | CSEL (multicolor, 40 columns, no horiz scroll)
// memptr (D018) = 0x18 (screen at $0400, bitmap at $E000 within
// the selected 16K VIC bank)
// //
// Calling show_screen() with an unsupported ID is a no-op. // Calling show_screen() with an unsupported ID is a no-op.
+132
View File
@@ -360,6 +360,131 @@ These items were done after Phase 8 was marked complete:
--- ---
## NUFLI Integration Plan
**Goal:** Replace the current multicolor bitmap screens with NUFLI format
for static screens (title, win, gameover). NUFLI provides 320×200 resolution
with ~10+ colors per block vs the current 160×200 with 4 colors per cell.
### Architecture
**Hybrid approach:**
- **Static screens** (title, win_hare, win_scoot, gameover): NUFLI format
- **Animated screens** (waiting1, waiting2, draw): Keep current multicolor bitmap
**Why hybrid:**
- NUFLI consumes 100% CPU during display (no game logic possible)
- 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
- [ ] Create `src/nufli.h` with display function declarations
- [ ] Create `src/nufli.c` with display routine wrapper:
- `nufli_show(const unsigned char *data)` — loads data to $2000, calls SYS 12288
- `nufli_exit()` — restores normal video mode
- [ ] Add `nufli_display.asm` to `src/` (6502 assembly for bank switching + JSR $3000)
#### Step 2: Screen state machine updates
- [ ] Modify `game_enter_title()` to use NUFLI for title screen
- [ ] Modify `game_enter_win_p1/p2()` to use NUFLI for win screens
- [ ] Modify `game_enter_gameover()` to use NUFLI for gameover screen
- [ ] Keep existing multicolor for waiting/draw screens
#### Step 3: Memory management
- [ ] Ensure NUFLI data ($2000-$7FFF) doesn't conflict with game code
- [ ] Verify screen RAM at $C400 doesn't overlap with NUFLI bitmap
- [ ] Test sprite pointer setup (NUFLI uses bank 3 sprites)
#### Step 4: State transitions
- [ ] Implement `nufli_exit()` to restore VIC-II state before returning to game
- [ ] Ensure raster IRQ is re-enabled after NUFLI display
- [ ] Test TITLE→READY transition (NUFLI→multicolor)
#### Step 5: Build pipeline integration
- [x]`make nufli` generates .asm files from source PNGs
- [x] ✅ NUFLI .asm files are build dependencies
- [ ] Add `#pragma embed` or linker includes for NUFLI data
- [ ] Verify total binary size fits in C64 memory
### Memory Map (NUFLI mode)
```
$2000-$7FFF: NUFLI data (bitmap + sprites + color tables)
$8000-$9FFF: Game code (oscar64 default)
$C000-$C3FF: Screen RAM (for multicolor screens)
$C400-$C7FF: Screen RAM (for NUFLI underlays)
$E000-$FFFF: Bitmap RAM (for multicolor screens)
```
### Verify
- `make nufli` generates all 5 screen .asm files
- `make compile` builds successfully with NUFLI data included
- `make run`: title screen displays in NUFLI quality (320×200, many colors)
- Press fire → transitions to multicolor waiting screen
- Win → displays NUFLI win screen
- Gameover → displays NUFLI gameover screen
---
## Phase 10 — Code review fixes (pending) ## Phase 10 — Code review fixes (pending)
Findings from the second round of 4-agent parallel code review. Findings from the second round of 4-agent parallel code review.
@@ -681,3 +806,10 @@ Phase 8: polish and end-to-end test
Within a phase, break up by file: "Phase 2: add memmap_setup and Within a phase, break up by file: "Phase 2: add memmap_setup and
show_screen helpers" before "Phase 2: wire into main.c". show_screen helpers" before "Phase 2: wire into main.c".
---
## Notes / open ideas
- The images did not fit into RAM. Maybe remaking them onto a common
background and only re-rendering parts would be a solution.
+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)
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Convert NUFLI .nuf binary to oscar64-compatible assembly/include files.
Usage:
python3 nuf_to_asm.py input.nuf output_base
Output files:
output_base.asm - Assembly data file
output_base.h - C header with extern declarations
"""
import argparse
import sys
from pathlib import Path
def nuf_to_asm(nuf_data, output_base):
"""Convert NUFLI binary to assembly data."""
out_path = Path(output_base)
# Skip 2-byte load address header
data = nuf_data[2:]
# Assembly data file
with open(out_path.with_suffix('.asm'), 'w') as f:
f.write("; NUFLI image data - generated by nuf_to_asm.py\n")
f.write("; Load at $2000, display with SYS 12288 ($3000)\n")
f.write(f"; Total size: {len(data)} bytes\n\n")
f.write(".segment \"NUFLI_DATA\"\n\n")
# Export the data
f.write(".export _nufli_data\n")
f.write(".export _nufli_size\n\n")
f.write("_nufli_data:\n")
# Write data in rows of 16 bytes
for i in range(0, len(data), 16):
chunk = data[i:i+16]
hex_bytes = ', '.join(f'${b:02x}' for b in chunk)
f.write(f" .byte {hex_bytes}\n")
f.write(f"\n_nufli_size = {len(data)}\n")
# C header file
with open(out_path.with_suffix('.h'), 'w') as f:
f.write("/* NUFLI image data - generated by nuf_to_asm.py */\n")
f.write(f"#ifndef {out_path.name.upper().replace('.', '_')}_H\n")
f.write(f"#define {out_path.name.upper().replace('.', '_')}_H\n\n")
f.write(f"/* NUFLI data size: {len(data)} bytes */\n")
f.write(f"extern const unsigned char nufli_data[{len(data)}];\n")
f.write(f"extern const unsigned int nufli_size;\n\n")
f.write("/* Display NUFLI image */\n")
f.write("void nufli_display(void);\n\n")
f.write("#endif\n")
print(f"Wrote {out_path.with_suffix('.asm')} ({len(data)} bytes)")
print(f"Wrote {out_path.with_suffix('.h')}")
def main(argv=None):
ap = argparse.ArgumentParser(
description="Convert NUFLI .nuf binary to oscar64 assembly"
)
ap.add_argument("input", help="Input .nuf file")
ap.add_argument("output_base", help="Output base path (no extension)")
args = ap.parse_args(argv)
in_path = Path(args.input)
if not in_path.exists():
print(f"Error: {in_path} not found", file=sys.stderr)
sys.exit(1)
data = in_path.read_bytes()
nuf_to_asm(data, args.output_base)
if __name__ == "__main__":
main()
+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()