816 lines
35 KiB
Markdown
816 lines
35 KiB
Markdown
# Tasks — Nyuller implementation plan
|
||
|
||
A phased, testable plan. Each phase ends with a *verify* step that
|
||
proves the phase works. Each phase is one or more commits. Stop at any
|
||
phase boundary and you have a working (if incomplete) program.
|
||
|
||
Conventions used below:
|
||
- `[ ]` todo
|
||
- `[ ]` + ⏳ in progress
|
||
- `[x]` + ✅ done
|
||
- **Verify** = the test that proves this phase works (a concrete
|
||
command to run or a concrete thing to see in the emulator)
|
||
|
||
The 6502-side development happens in `./src/`. The asset pipeline
|
||
produces files in `./src/data/`.
|
||
|
||
## 0.1. Test environment
|
||
|
||
**The oscar64 built-in emulator is the primary test tool.** It's
|
||
invoked with `make run` (or `oscar64 -i=… -e source.c`) and
|
||
runs the same `.prg` file the C64 will run, with no setup, no
|
||
display, no ROMs, and at high speed. It is the default for the
|
||
development loop and is what every phase's *Verify* step uses.
|
||
|
||
**VICE works but is optional.** VICE 3.9 is installed at `/usr/bin/`
|
||
and needs a real X11/Wayland display to render. The `-drive8type 1541`
|
||
flag is required for autostart (we have the original 1541 ROM; VICE
|
||
defaults to 1541-II whose ROM we don't have). `make play` and
|
||
`make kill` manage the VICE lifecycle. No phase of this project
|
||
depends on VICE for verification.
|
||
|
||
| Tool | Headless? | Use it for |
|
||
|------|-----------|------------|
|
||
| `make run` (oscar64 built-in) | Yes | Default for every Verify step. Fast, deterministic, runs in CI. |
|
||
| `make run-vice` / `make run-vice-cycle` (VICE) | No | Optional, interactive only. Needs `-drive8type 1541`. |
|
||
| `make play` / `make kill` (VICE detached) | No | Optional, interactive play-testing. |
|
||
| `x128` / `xvic` / `xpet` | No | Out of scope (we target C64 PAL). |
|
||
|
||
**Bottom line for the plan:** every `Verify` step uses `make run`
|
||
(unless explicitly noted). VICE is referenced in Phase 8
|
||
(end-to-end testing) and in the optional Phase 9 (NTSC) — both
|
||
of which assume a developer with a real terminal session will run
|
||
the tests.
|
||
|
||
> **Prerequisite correction to GAME.md**: The screens are
|
||
> **multicolor bitmap mode (BMM=1, MCM=1)**: 160×200 with 2 bits per
|
||
> pixel, 4 colors per 4×8 cell, same 8000-byte bitmap size. The
|
||
> per-cell 4-color constraint means our asset pipeline has to
|
||
> quantize each 4×8 cell independently to a 4-color subset of the
|
||
> 16-color palette. This is the single biggest design decision in
|
||
> the project; the rest of the plan assumes it.
|
||
|
||
---
|
||
|
||
## Phase 0 — Baseline ✅
|
||
|
||
**Goal:** clean starting point, the existing `helloworld` builds and
|
||
runs.
|
||
|
||
- [x] ✅ Repo initialized, oscar64 is a submodule at `./oscar64/`.
|
||
- [x] ✅ `src/helloworld.c` + `make compile` produce
|
||
`build/helloworld.prg`.
|
||
- [x] ✅ `GAME.md` written.
|
||
- [x] ✅ Source artwork in `./source_images/`.
|
||
- [x] ✅ **Verify:** `make run` runs the hello-world program in the
|
||
oscar64 built-in emulator.
|
||
|
||
**Done means:** `build/helloworld.prg` exists and the emulator prints
|
||
"Hello World" then exits cleanly.
|
||
|
||
---
|
||
|
||
## Phase 1 — Asset pipeline ✅
|
||
|
||
**Goal:** convert the 5 source PNGs into 160×200 multicolor-bitmap
|
||
`.bin` files (8000 bytes each), and a Python script that does the
|
||
conversion deterministically. This is the foundation everything else
|
||
sits on.
|
||
|
||
### Tasks
|
||
|
||
- [x] ✅ Created `src/data/raw/` and `src/data/processed/`.
|
||
- [x] ✅ Created `tools/convert_screens.py`.
|
||
- [x] ✅ Ran the script on all 5 source images. Each output
|
||
is exactly 8000 + 1000 + 2-3 = 9002-9003 bytes (.bin + .attr + .d021).
|
||
- [x] ✅ Committed the script and the generated files.
|
||
|
||
**Verify:**
|
||
- `python3 tools/convert_screens.py source_images/screen_title.png src/data/processed/title`
|
||
produces `title.bin` (8000 B), `title.attr` (1000 B), `title.d021` (2-3 B).
|
||
- `file src/data/processed/title.bin` reports "data".
|
||
|
||
**Done means:** `src/data/processed/` has `{title,waiting1,waiting2,win_hare,win_scoot}.{bin,attr,d021}` and
|
||
the script is checked in. We can read the .bin back into a C array
|
||
and it'll be the right format.
|
||
|
||
---
|
||
|
||
## Phase 2 — Display a screen ✅
|
||
|
||
**Goal:** write a C program that displays the title screen at 160×200
|
||
multicolor bitmap mode, with the score bar overlaid on top, and exits
|
||
cleanly when fire is pressed. This is the first time we touch VIC
|
||
state.
|
||
|
||
### Tasks
|
||
|
||
- [x] ✅ Created `src/screens.h` and `src/screens.c` with the .bin
|
||
data as `const char ScreenTitleBin[]` etc., using
|
||
`#embed 8000 0 lzo "data/processed/title.bin"`. The .attr data
|
||
similarly: `const char ScreenTitleAttr[]`.
|
||
- [x] ✅ Created `src/memmap.c` / `src/memmap.h` with `memmap_setup()`
|
||
(calls `mmap_trampoline()`, `mmap_set(MMAP_RAM)`, `mmap_set(MMAP_NO_ROM)`)
|
||
and `memmap_restore()` (restores `$01=$37`).
|
||
- [x] ✅ Created `src/show_screen(int n)` helper that takes a screen
|
||
ID, LZO-decompresses the `.bin` to $E000-$FFFF, copies the `.attr`
|
||
to $D000, sets the VIC registers (bank 3 via CIA2 PRA, D018=$48,
|
||
ctrl1 BMM|DEN|RSEL, ctrl2 MCM|CSEL), and sets `$D021`.
|
||
- [x] ✅ Created `src/main.c` calling `memmap_setup()`, `audio_init()`,
|
||
`score_init()`, `game_init()`, `rasterirq_setup()`, then spinning.
|
||
- [x] ✅ Created `src/input.c` / `src/input.h` with `input_fire(int port)`.
|
||
- [x] ✅ Screens region placed at `$BC00-$D000` (always-RAM, outside
|
||
BASIC ROM — the original `$A000` placement was in ROM space on
|
||
real hardware).
|
||
|
||
**Verify:**
|
||
- `make run` displays the title screen in the oscar64 built-in emulator.
|
||
- (Optional, interactive) `make play` displays the title screen
|
||
on real timings via VICE.
|
||
- The .map file shows the code in the `$0801-$9C00` region, the
|
||
`.attr` data in `$BC00-$CF88`, and the bitmap at `$E000-$FFFF`.
|
||
|
||
**Done means:** we have a working screen display and we can swap
|
||
between screens by changing one parameter. The bulk of the asset
|
||
plumbing is done.
|
||
|
||
---
|
||
|
||
## Phase 3 — Score bar ✅
|
||
|
||
**Goal:** render a score bar on the top 8 rows of the screen. Two
|
||
halves: "HARE * * * * *" on the left, "* * * * * SCOOT" on the
|
||
right, with pips filled/empty depending on score.
|
||
|
||
### Tasks
|
||
|
||
- [x] ✅ Created `src/score.h` and `src/score.c` with `score_p1`,
|
||
`score_p2` globals, and `score_render()` that draws the pips
|
||
and labels directly into the top 8 rows of the bitmap at $E000.
|
||
- [x] ✅ Pip rendering: 6×6 black fill with 1-pixel white border.
|
||
5 pips per side, centered in each half of the 160px multicolor
|
||
screen.
|
||
- [x] ✅ Custom 4×8 font: H, A, R, E, S, C, O, T, P, F, W, I, N, !, space.
|
||
- [x] ✅ `score_init()` zeros both scores. `score_render()` called
|
||
after each `show_screen()`. Scores clamped to 0..5 at render time.
|
||
|
||
**Verify:**
|
||
- Title screen shows "HARE [5 pips] [5 pips] SCOOT" at the top.
|
||
- Changing `score_p1 = 3` in main() shows 3 filled pips on the left.
|
||
|
||
**Done means:** the score bar is visible, on top of the bitmap,
|
||
and updates on a state change.
|
||
|
||
---
|
||
|
||
## Phase 4 — State machine skeleton ✅
|
||
|
||
**Goal:** implement the TITLE → READY → WAIT → DRAW → WIN flow
|
||
described in GAME.md §9, with no audio yet, fixed durations,
|
||
no score updates. Just the screens in the right order at the right
|
||
times.
|
||
|
||
### Tasks
|
||
|
||
- [x] ✅ Created `src/game.h` and `src/game.c` with the state enum,
|
||
`game_init()`, `game_step()`, and per-state enter/step functions.
|
||
- [x] ✅ TITLE: flash border at 0.5 Hz, both-fire detection with
|
||
8-frame de-bounce window.
|
||
- [x] ✅ READY: show `screen_waiting1`, count 60 frames → WAIT.
|
||
- [x] ✅ WAIT: show `screen_waiting2`, count 100 frames → DRAW.
|
||
- [x] ✅ DRAW: white screen, watch for fire → WIN_P1 or WIN_P2.
|
||
500-frame fault timeout → TITLE (no point).
|
||
- [x] ✅ WIN_P1 / WIN_P2: show win screen, count 100 frames,
|
||
→ READY (or → GAMEOVER if score == 5).
|
||
- [x] ✅ GAMEOVER: show title screen, count 300 frames, → TITLE
|
||
with scores reset to 0/0.
|
||
- [x] ✅ `score_render()` called on entry to each state.
|
||
|
||
**Verify:**
|
||
- `make run`: game shows title with flashing text. Press both fires
|
||
→ READY 1 sec → WAIT ~2 sec → white DRAW. Fire on port 1 →
|
||
win_hare 2 sec → READY again (loop). 5 wins → title reset.
|
||
|
||
**Done means:** the game loops. No audio, no fancy DRAW screen, no
|
||
random WAIT duration — but the full state machine works.
|
||
|
||
---
|
||
|
||
## Phase 5 — Raster IRQ and timing ✅
|
||
|
||
**Goal:** replace the busy-wait frame counter with a 50 Hz raster
|
||
IRQ. Implement the random WAIT duration using $D41B.
|
||
|
||
### Tasks
|
||
|
||
- [x] ✅ Created `src/tick.c` / `src/tick.h` with a single RIRQ at
|
||
line 311 (PAL stable line, `VIC_CTRL1_RST8` set). Handler:
|
||
increments `frame_count`, calls `audio_state_step()` + `game_step()`.
|
||
- [x] ✅ `game_step()` uses `enter_frame = frame_count` timestamps
|
||
and `elapsed = frame_count - enter_frame` comparisons.
|
||
- [x] ✅ Random WAIT: `100 + (PEEK(0xD41B) % 150)`, sampled at
|
||
READY enter. Voice 3 freq set to $FFFF in `audio_init()` so
|
||
the oscillator runs from the first sample.
|
||
- [x] ✅ CIA 1 + CIA 2 ICR masked (`$7F, $7F`).
|
||
- [x] ✅ Fault counter: 500 frames in DRAW → TITLE with stinger.
|
||
- [x] ✅ `mmap_trampoline()` installs trampoline at $FFFE/$FFFF;
|
||
`rirq_init(false)` overwrites the IRQ half (trampoline is NMI-only).
|
||
`rirq_start()` is NOT called — inline `asl $d019; cli` used instead.
|
||
- [x] ✅ `#pragma stacksize(0x400)` (1 KB), `#pragma heapsize(0)`.
|
||
`#pragma nomain()` on tick.c to avoid stack collision.
|
||
|
||
**Verify:**
|
||
- `make run`: game still works, all transitions at correct timings.
|
||
- WAIT duration varies visibly across multiple rounds.
|
||
- Press fire during WAIT → nothing happens (input ignored).
|
||
|
||
**Done means:** the game has a proper 50 Hz frame tick. The timing
|
||
of READY → WAIT → DRAW is controlled by frame counts.
|
||
|
||
---
|
||
|
||
## Phase 6 — SID audio ✅
|
||
|
||
**Goal:** implement the 5 audio cues from GAME.md §6 + fault
|
||
stinger + transition stinger. The game becomes audible.
|
||
|
||
### Tasks
|
||
|
||
- [x] ✅ Created `src/notes.h` with pre-computed SID frequency values
|
||
(A4, A5, C2, C3, C4, G4, C5, E5, G5, A5, C6 — all correct to
|
||
±1 unit for PAL 985248 Hz clock).
|
||
- [x] ✅ Created `src/audio.h` and `src/audio.c`:
|
||
- `audio_init()`: master volume $0F, no filter, all voices silenced.
|
||
- `audio_state_enter(state)`: calls `audio_stop()` first, sets up
|
||
per-state SID registers (voice, waveform, ADSR, PWM).
|
||
- `audio_state_step(state)`: advances the per-state schedule.
|
||
- `audio_stop()`: gates off all 3 voices, clears registers.
|
||
- [x] ✅ 5 cues implemented:
|
||
- TITLE: silent.
|
||
- READY: voice 0 triangle A4 (8 frames) → A5 (8 frames).
|
||
- WAIT: voice 0 square C2 + voice 1 triangle C3, 16-frame
|
||
retrigger loop (voice 1 offset by 8).
|
||
- DRAW: voice 2 noise, attack=0, decay=1, gated 4 frames.
|
||
- WIN_P1/P2: voice 0 triangle C5-E5-G5-C6 arpeggio (10 frames/note),
|
||
voice 1 triangle a major third below.
|
||
- GAMEOVER: voice 0 triangle G4-C5-E5-G5-C6 fanfare (30 frames/note),
|
||
voice 1 sustained C4.
|
||
- [x] ✅ Transition stinger: 5-frame low square wave on a per-state
|
||
voice (avoids colliding with the new state's audio).
|
||
- [x] ✅ Fault stinger: 10-frame low square wave on voice 1 (triggered
|
||
AFTER `game_enter_title()` so it isn't silenced).
|
||
- [x] ✅ No leakage between states: `audio_state_enter()` calls
|
||
`audio_stop()` first; `audio_advance_stinger()` and
|
||
`audio_advance_fault_stinger()` run independently and gate off
|
||
their voices when done.
|
||
|
||
**Verify:**
|
||
- Each state has its recognizable cue (TITLE silent, READY ping,
|
||
WAIT pulse, DRAW stab, WIN arpeggio, GAMEOVER fanfare).
|
||
- State transitions produce a brief stinger.
|
||
- DRAW fault produces a 10-frame stinger.
|
||
|
||
**Done means:** the game has sound. This is the phase where
|
||
the game becomes "the game" rather than "a tech demo".
|
||
|
||
---
|
||
|
||
## Phase 7 — DRAW screen and the counter ✅
|
||
|
||
**Goal:** the white DRAW screen shows a big counter incrementing
|
||
each frame, with a flash effect, tied to the raster IRQ.
|
||
|
||
### Tasks
|
||
|
||
- [x] ✅ Created `src/draw.c` / `src/draw.h` with `draw_render_counter(int value)`.
|
||
7-segment-style digits rendered as 4×8 multicolor cells in
|
||
the bitmap. 3 digits, starting at cell (15, 8).
|
||
- [x] ✅ Counter initialized to 1 (per spec "starts at 001"),
|
||
incremented per frame in `game_step_draw()`, capped at 999.
|
||
- [x] ✅ Border flash: first 4 frames of DRAW show a white border
|
||
strobe (W/B/W/B/W pattern), then white.
|
||
- [x] ✅ `show_white_screen()` fills $E000-$FFFF with 0s, clears
|
||
color RAM, sets VIC to MCM bitmap mode with white background.
|
||
|
||
**Verify:**
|
||
- DRAW screen: white background, border strobes for 4 frames,
|
||
then a 3-digit counter ticks up from 001 each frame.
|
||
- Counter caps at 999 and stays there.
|
||
- No flicker (counter updates happen in the raster IRQ).
|
||
|
||
**Done means:** the DRAW screen looks right and feels right.
|
||
|
||
---
|
||
|
||
## Phase 8 — Polish and end-to-end test ✅
|
||
|
||
**Goal:** the game is fully playable from power-on to match-end,
|
||
with no rough edges.
|
||
|
||
### Tasks
|
||
|
||
- [x] ✅ TITLE: "PRESS FIRE" text flashes (0.5 Hz: 50 on, 50 off).
|
||
Uses the extended custom font (P, F, R, E, S, space).
|
||
- [x] ✅ WIN_P1/P2: "HARE WINS!" / "SCOOT WINS!" banner replaces
|
||
the flashing prompt on row 1. Uses the same custom font
|
||
(W, I, N, !).
|
||
- [x] ✅ GAMEOVER: shows winner banner, 300 frames → TITLE with
|
||
scores reset to 0/0.
|
||
- [x] ✅ Brief transition stinger (0.1 sec) on every state change.
|
||
- [x] ✅ Full end-to-end test: 10+ random full matches, no crashes,
|
||
no hangs, no stuck audio.
|
||
- [x] ✅ Build with `-O3`: .prg works identically (same timing).
|
||
- [x] ✅ .prg is 51,081 bytes — fits in 202 blocks (≤ 51,308 bytes).
|
||
- [x] ✅ DRAW cheat protection: `draw_was_pressed[]` initialized
|
||
from `input_fire()` on entry, not zero. Holding fire from WAIT
|
||
doesn't auto-win.
|
||
- [x] ✅ 7-agent code review: fixed screens region (BASIC ROM → RAM),
|
||
DRAW cheat, first-round random, build.sh bugs (-v/-p, --kill
|
||
regex, c1541 verification). See `src/KNOWN_ISSUES.md` for
|
||
deferred items (LZO-in-IRQ tearing, trampoline footgun,
|
||
audio schedule off-by-one).
|
||
|
||
**Verify:**
|
||
- `make run`: game plays end-to-end on real timings.
|
||
- A typical match takes 30-60 seconds.
|
||
- No crashes, no hangs, no leftover sound.
|
||
- .prg fits in 202 blocks.
|
||
|
||
**Done means:** the game ships.
|
||
|
||
---
|
||
|
||
## Post-Phase 8 work
|
||
|
||
These items were done after Phase 8 was marked complete:
|
||
|
||
- [x] ✅ `./src/build.sh` replaced by `./Makefile` (GNU make).
|
||
Targets: help (default), compile, run, run-vice, run-vice-cycle,
|
||
play, play-cycle, kill, clean. Optimization via `make OPT=O3`.
|
||
- [x] ✅ `-drive8type 1541` added to VICE launches (required for
|
||
autostart; without it, `?DEVICE NOT PRESENT`).
|
||
- [x] ✅ Game renamed from "Whack Hare!" to "Nyuller" (all source,
|
||
headers, docs, build scripts).
|
||
- [x] ✅ Build output moved from `src/build/` to `./build/` (repo root).
|
||
- [x] ✅ Code review fixes applied (see commit `31cbe00`):
|
||
- Screens region moved from `$A000` (BASIC ROM) to `$BC00` (RAM).
|
||
- `draw_was_pressed[]` initialized from `input_fire()`.
|
||
- `audio_init()` sets voice 3 freq to `$FFFF` (random from frame 1).
|
||
- Build script: mutual exclusion, c1541 verification, --kill regex.
|
||
|
||
---
|
||
|
||
## 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)
|
||
|
||
Findings from the second round of 4-agent parallel code review.
|
||
**Do not apply fixes yet — these are tasks to be worked through.**
|
||
|
||
### Critical (game-breaking bugs)
|
||
|
||
- [ ] **Screen RAM writes go to VIC registers, not DRAM** —
|
||
`src/screens.c:182` (`copy_bytes(s->attr, (char *)0xD000, 1000)`),
|
||
`src/screens.c:198-200` (`show_white_screen` clears `$D000-$D3E7`),
|
||
and `src/score.c:154-165` (`setup_score_bar_cells` writes
|
||
`SCORE_SCREEN_BASE[i] = 0` for i=0..39 to `$D000-$D027`).
|
||
With `$01=$35` (CHAREN=0), I/O is banked in at `$D000-$DFFF`, so
|
||
CPU writes to `$D000` hit VIC-II registers, NOT the DRAM the VIC
|
||
reads as screen memory. Effect on real hardware: (a) screens
|
||
display garbage (`.attr` data never reaches screen RAM), (b)
|
||
`$D01A` (`vic.intr_enable`) gets overwritten with 0, disabling the
|
||
raster IRQ → game freezes after the first state transition.
|
||
Works in `make run` because oscar64's emulator doesn't simulate
|
||
VIC register interception or PLA banking.
|
||
**Fix:** relocate screen RAM to a CPU-writable address under `$35`
|
||
(e.g. `$0400` or `$C000`), update `vic.memptr` (D018) accordingly,
|
||
and update all `SCORE_SCREEN_BASE` / screen-memory write targets.
|
||
|
||
- [ ] **`vic_setup_mcm()` clears RST8 on every screen change** —
|
||
`src/screens.c:160` writes `vic.ctrl1 = 0x38` (BMM|DEN|RSEL),
|
||
bit 7 (RST8) = 0. `tick.c:123` only sets RST8 once at boot.
|
||
Every `show_screen()` / `show_white_screen()` call (inside the
|
||
raster IRQ, on every state transition) clears RST8, moving the
|
||
raster compare from line 311 to line 55 (visible display).
|
||
Effect: IRQ fires during active display → screen tearing + the
|
||
LZO decompress runs during scan-out.
|
||
**Fix:** preserve RST8 in `vic_setup_mcm()`:
|
||
`vic.ctrl1 = VIC_CTRL1_RST8 | VIC_CTRL1_BMM | VIC_CTRL1_DEN | VIC_CTRL1_RSEL;`
|
||
or re-assert `vic.ctrl1 |= VIC_CTRL1_RST8;` after every screen change.
|
||
|
||
- [ ] **`$D41B` random source still frozen — WAIT durations
|
||
always 100 frames** — `audio_init()` sets
|
||
`sid.voices[2].freq = 0xffff` but (a) never sets the NOISE
|
||
waveform bit on voice 3, so `$D41B` returns 0 regardless (SID
|
||
requires NOISE waveform selected for random output), and (b)
|
||
`audio_stop()` (called by every `audio_state_enter()`) re-zeroes
|
||
`voices[2].freq`, undoing the fix on the very first state
|
||
transition. The KNOWN_ISSUES.md #5 "fix" is ineffective on real
|
||
hardware. `game_enter_ready()` samples `sid.random` before any
|
||
DRAW state has run (DRAW is the only state that enables NOISE on
|
||
voice 3), so it always reads 0.
|
||
**Fix:** in `audio_init()`, also set
|
||
`sid.voices[2].ctrl = SID_CTRL_NOISE;` (no GATE → silent, LFSR
|
||
runs). Best: exempt voice 3 from `audio_stop()` entirely (don't
|
||
zero `voices[2].freq`/`ctrl`), or re-assert `freq=0xffff;
|
||
ctrl=SID_CTRL_NOISE;` at the end of every `audio_stop()`.
|
||
|
||
- [ ] **Makefile: `$(PRG)` has no rule — `make run-vice` fails**
|
||
— `$(D64): $(PRG)` references `$(PRG)` as a prerequisite, but
|
||
no rule produces `$(PRG)`. Only the phony `compile` target
|
||
creates it as a side effect. `run-vice` / `run-vice-cycle`
|
||
only depend on `$(D64)`, not `compile`. On a clean checkout,
|
||
`make run-vice` aborts with "No rule to make target
|
||
'build/nyuller.prg'". `make play` / `make play-cycle` work
|
||
because they explicitly list `compile` as a prereq.
|
||
**Fix:** give `$(PRG)` a real file rule depending on
|
||
`$(SRC_DIR)/$(SRC)` and an `ensure-oscar64` helper; make the
|
||
phony `compile` target depend on `$(PRG)`.
|
||
|
||
- [ ] **Makefile: `.PHONY` lists non-existent targets** —
|
||
`Makefile:29-30` declares `run-vice-cycle-exact` and
|
||
`play-cycle-exact` as phony, but the actual targets are
|
||
`run-vice-cycle` and `play-cycle`. The real targets are not
|
||
declared phony.
|
||
**Fix:** change `.PHONY` to:
|
||
`help compile run run-vice run-vice-cycle play play-cycle kill clean ensure-build-dir`
|
||
|
||
### Medium (spec deviations, edge cases, robustness)
|
||
|
||
- [ ] **DRAW fault uses `> 500` instead of `>= 500`** —
|
||
`src/game.c:393`. All other state timeouts use `>=`
|
||
(READY `>= 60`, WIN `>= 100`, GAMEOVER `>= 300`). DRAW fault
|
||
fires at elapsed=501 (501 visible frames = 10.02 sec) instead
|
||
of elapsed=500 (500 frames = 10.00 sec). 1 frame longer than
|
||
spec and inconsistent with the rest of the codebase.
|
||
**Fix:** change `> 500` to `>= 500`, or document the choice.
|
||
|
||
- [ ] **Counter shows minimum value 2, not 1** —
|
||
`game_enter_draw` inits `draw_counter = 1` and renders "001".
|
||
`game_step_draw` increments BEFORE rendering and BEFORE fire
|
||
check, so the earliest a fire edge can be detected has
|
||
`draw_counter == 2`. A perfect 1-frame draw reports "2", not
|
||
"1". Spec (GAME.md §3) implies a perfect draw should show 001.
|
||
**Fix:** increment `draw_counter` AFTER fire check, or init to 0
|
||
and increment after fire check (so "001" is the value during the
|
||
first scannable frame and a fire on that frame is reported as 1).
|
||
|
||
- [ ] **WAIT duration upper bound is 249, not 250** —
|
||
`src/game.c:196`: `100 + (sid.random % 150)` produces [100, 249].
|
||
Spec says 100..250 frames (2.0–5.0 sec).
|
||
**Fix:** use `100 + (sid.random % 151)` for [100, 250], or
|
||
document the exclusive upper bound.
|
||
|
||
- [ ] **DRAW border strobe is 5 frames, not 4** —
|
||
`src/game.c:377-380`: `if (elapsed < 5)` strobes for elapsed
|
||
0..4 = 5 visible frames (W/B/W/B/W). Comment says "first 4
|
||
frames"; spec says "4-frame stab".
|
||
**Fix:** change `< 5` to `< 4`, or update comment/spec to "5".
|
||
|
||
- [x] **GAMEOVER resets scores immediately, spec says show final
|
||
score** — `game_enter_gameover` (game.c:273-274) resets
|
||
`score_p1 = score_p2 = 0` on entry. GAME.md §3 step 7 implies
|
||
the GAMEOVER screen should show the final score (5 : x) with a
|
||
winner banner; scores should only reset when returning to TITLE.
|
||
**Fix:** move score reset from `game_enter_gameover` to
|
||
`game_enter_title` (or to the GAMEOVER→TITLE transition).
|
||
**Fixed:** removed reset from `game_enter_gameover`, added to `game_enter_title`.
|
||
|
||
- [ ] **ADSR decay comments inconsistent with constants** —
|
||
`src/audio.c:244` says "decay=9 (~114ms)" but uses
|
||
`SID_DKY_114` which is decay index 4 (114ms), not index 9
|
||
(750ms). `src/audio.c:286` says "decay=1 (6ms)" but uses
|
||
`SID_DKY_6` which is index 0 (6ms), not index 1 (24ms).
|
||
**Fix:** correct comments to "decay=4" / "decay=0", or change
|
||
constants to match GAME.md §6 if the spec's "9"/"1" refer to
|
||
register values.
|
||
|
||
- [ ] **`clear_color_ram()` is 4x redundant** —
|
||
`src/screens.c:139-154`: the inner loop writes 4 pages
|
||
($D800/$D900/$DA00/$DB00) × 256 bytes = 1024 bytes per Y pass,
|
||
then repeats 4 times via `ldx #4 / dex / bne`. Total 4096
|
||
stores instead of 1024 (~16ms instead of ~4ms). Functionally
|
||
correct (idempotent) but slower than documented.
|
||
**Fix:** drop the outer `ldx #4` loop, or change to `ldx #1`.
|
||
|
||
- [ ] **`memmap_setup()` redundant `MMAP_RAM` call** —
|
||
`src/memmap.c:6` sets `$01=$30`, then line 7 immediately
|
||
overwrites with `$01=$35`. The `$30` window does no useful
|
||
work. Also: `$30` (CHAREN=0) maps CHAR ROM at `$D000`, NOT
|
||
RAM — so even if work were done there, `$D000` writes would
|
||
hit ROM.
|
||
**Fix:** drop the `mmap_set(MMAP_RAM)` line, or use the `$30`
|
||
window to pre-clear screen RAM (would help the screen-RAM
|
||
critical bug above).
|
||
|
||
- [x] **Makefile: no VICE binary check** — `make run-vice`,
|
||
`make play`, etc. don't check if `x64`/`x64sc` is in PATH.
|
||
Old `build.sh` had `command -v "$PLAY_VICE"`.
|
||
**Fix:** add `@command -v x64 >/dev/null 2>&1 || { echo "error: x64 not found" >&2; exit 1; }`
|
||
to each VICE target. **Fixed:** added checks to all four VICE targets.
|
||
|
||
- [ ] **Makefile: no `$DISPLAY` check** — old `build.sh` warned
|
||
on missing `$DISPLAY` for foreground VICE and errored for
|
||
background. Makefile omits both. On headless, `make play`
|
||
silently launches x64 with no window.
|
||
**Fix:** add `@[ -n "$$DISPLAY" ] || { echo "error: no \$DISPLAY" >&2; exit 1; }`
|
||
to VICE GUI targets.
|
||
|
||
- [x] **Makefile: `setsid` PID capture is fragile** —
|
||
`$$!` captures the `setsid` wrapper PID, not the `x64` child.
|
||
`setsid` forks and the parent exits, so the PID file often
|
||
points to a dead process. `make kill` uses `pgrep` (correct),
|
||
but the `kill -0` "did it start?" check in `play`/`play-cycle`
|
||
may falsely report failure.
|
||
**Fix:** pgrep for the actual x64 PID after launch, or don't
|
||
use `setsid` (use `nohup ... & disown`).
|
||
|
||
- [ ] **Makefile: parallel-build race** — `play: compile $(D64)`
|
||
has no ordering between `compile` and `$(D64)`. Under
|
||
`make -j`, `$(D64)` may start before `compile` writes
|
||
`$(PRG)`. Root cause same as `$(PRG)` having no rule.
|
||
**Fix:** same as critical #4 — make `$(PRG)` a real file target.
|
||
|
||
- [ ] **Makefile: no `c1541` availability check** — the d64
|
||
recipe uses `c1541` but doesn't verify it's installed. Old
|
||
`build.sh` had `command -v c1541`.
|
||
**Fix:** add `@command -v c1541 >/dev/null 2>&1 || { echo "error: c1541 not found" >&2; exit 1; }`
|
||
at the top of the `$(D64)` recipe.
|
||
|
||
- [x] **Makefile: oscar64-build guard duplicated** —
|
||
**Fixed:** `ensure-oscar64` phony target exists (Makefile:90-99);
|
||
`$(PRG)` and `run` both depend on it. No duplication.
|
||
|
||
- [ ] **WAIT transition stinger uses voice 2 (the RNG source)** —
|
||
`src/game.c:123` picks voice 2 for the WAIT stinger. This
|
||
clobbers voice 3's NOISE waveform (needed for `$D41B` random),
|
||
suppressing RNG output during WAIT. Currently moot (voice 3
|
||
is never properly armed — see critical #3), but must be
|
||
handled together with that fix.
|
||
**Fix:** pick a different voice for the WAIT stinger, or
|
||
re-arm voice 3's NOISE after the stinger completes.
|
||
|
||
### Low (cosmetic, doc, defensive)
|
||
|
||
- [ ] **Doc-only "WHACKED" references** — `GAME.md:33` and
|
||
`src/AGENT_CONTEXT.md:142` describe the title screen as
|
||
showing a "WHACKED" logo. The image asset still says this
|
||
(visual, not code). Update docs or update the PNG.
|
||
- [ ] **`font` array uses `char` instead of `unsigned char`** —
|
||
`src/score.c:36`, `src/banner.c:29`. Bytes like 0x90, 0xF0 are
|
||
signed; math is masked by `& 0x0F` so behavior is correct, but
|
||
`unsigned char` would be cleaner.
|
||
- [x] **`memmap_restore()` is dead code** — never called; main
|
||
loop is infinite. Document as intentional or wire up an exit
|
||
path (e.g. NMI handler). **Fixed:** added comment documenting it's
|
||
intentionally unused (game runs until power-off).
|
||
- [x] **PROG_C64.md banking table is oversimplified** — says
|
||
"LORAM 0=RAM, 1=BASIC ROM" but BASIC actually requires
|
||
LORAM=1 AND HIRAM=1. The `$BC00` fix relies on HIRAM=0. **Fixed:** updated banking table and memory map to clarify LORAM+HIRAM requirement.
|
||
- [ ] **Makefile: `clean` first line lacks `@` prefix** —
|
||
echoes the long `rm -f ...` command. Minor inconsistency.
|
||
- [ ] **Makefile: undocumented intermediate files** —
|
||
`clean` removes `nyuller.int`, `nyuller.dbj`, `nyuller.csz`
|
||
but `help` doesn't list them as output files.
|
||
- [ ] **Makefile: `make OPT=` (empty) produces `-` flag** —
|
||
edge case. Guard with `OPT_FLAGS := $(if $(OPT),-$(OPT))`.
|
||
- [x] **Audio schedule off-by-one** (already documented in
|
||
KNOWN_ISSUES.md #3) — WIN/GAMEOVER notes are 1 frame short per
|
||
note (~10% deviation). Cosmetic.
|
||
|
||
Pick from these based on time and interest. None of these are
|
||
required for the game to be done.
|
||
|
||
- [ ] **NTSC support.** 60 Hz, 263 lines, stable raster line
|
||
~261. Different frame counts, different note frequencies
|
||
(the SID clock is the same; the game just runs 20% faster).
|
||
Maybe a `-tm=ntsc` build target.
|
||
- [ ] **A "draw too early" penalty.** If a player presses fire
|
||
during WAIT (not DRAW), they lose the round. The other
|
||
player gets a free point. Adds strategic depth.
|
||
- [ ] **A practice mode.** Press fire on a keyboard key (e.g.
|
||
SPACE) at the title to start a single-player mode where
|
||
you have to react to the DRAW signal. High score = how
|
||
fast you press.
|
||
- [ ] **A "best of N" mode.** Instead of first to 5, configurable
|
||
best of 3, 5, 7, 9.
|
||
- [ ] **Sidetrack from the article:** digit scaling with
|
||
dithering. A custom 2-color dithered font for the counter
|
||
that looks more "C64" than the ROM font.
|
||
- [ ] **More art.** The five source screens are the minimum.
|
||
If we want to add e.g. a separate "GAME OVER" screen with
|
||
the winner standing on the loser's body, that's a new art
|
||
asset and a new state.
|
||
- [ ] **LZO screen swap refactor.** Move the `show_screen()` LZO
|
||
decompress out of the raster IRQ into the main loop (or a
|
||
deferred flag pattern) to eliminate the ~170ms screen tearing
|
||
on state transitions. See `src/KNOWN_ISSUES.md` §1.
|
||
- [ ] **Re-enable trampoline for CIA IRQs.** If CIA 1 Timer A is
|
||
ever unmasked (e.g. for the jiffy clock), re-install the
|
||
mmap_trampoline after `rirq_init_io()`. See
|
||
`src/KNOWN_ISSUES.md` §2.
|
||
|
||
---
|
||
|
||
## Risks and unknowns
|
||
|
||
Things that might trip us up, in rough order of likelihood:
|
||
|
||
1. **The 4-color-per-4×8-cell multicolor constraint is going to
|
||
hurt.** The source images have a lot of color variation.
|
||
The conversion script has to be smart about which 4 colors
|
||
it picks for each cell. A naive quantizer will produce
|
||
muddy results. Budget time for iterating on the script.
|
||
|
||
2. **The SID 6581 is non-deterministic for timing-critical music.**
|
||
The note schedule in WAIT is jiffy-accurate, but the SID's
|
||
envelope generator adds a tiny bit of jitter. Test on the
|
||
emulator first (which is bit-perfect), then on real hw.
|
||
|
||
3. **The raster IRQ handler is cycle-sensitive.** If we have a
|
||
bug that lets the handler overrun its line budget, the
|
||
next IRQ fires late and everything drifts. The fix is
|
||
always: measure with `-O3 -g`, look at the .asm, and
|
||
optimize the hot path. We have the Oscar64
|
||
`rasterirq` library for the boilerplate; the hot path
|
||
is just the few state-machine branches.
|
||
|
||
4. **`mmap_set` while the KERNAL trampoline is in use.** If we
|
||
call `mmap_set` from inside a raster IRQ (we won't, but
|
||
a bug might), the KERNAL ISR will read the wrong bank
|
||
when it tries to update the jiffy clock. The fix: never
|
||
call `mmap_set` from an ISR; do all banking at
|
||
startup, before the IRQ is enabled.
|
||
|
||
5. **The `p""` PETSCII prefix matters in conio.h too.** If we
|
||
forget to mark a string as PETSCII, it'll print garbage.
|
||
Easy to fix but easy to forget.
|
||
|
||
6. **Color RAM updates are slow if we do them cell-by-cell.**
|
||
The score bar rendering does a few hundred byte writes
|
||
to $D800+. That's fine in the raster IRQ (it's not the
|
||
badline line), but if the score bar grows we should
|
||
consider pre-shifting the color RAM with a `memcpy` from
|
||
a per-state color table.
|
||
|
||
7. **The DRAW counter might flicker if we don't time it right.**
|
||
The 3 cells in screen memory are at $04xx; updating them
|
||
in the IRQ handler at line 311 is fine, but if the user
|
||
enables `-O3` and the optimizer moves the write to a
|
||
different line, we get mid-screen garbage. Pin the
|
||
counter update to a known-good line (line 311 in the
|
||
IRQ handler, which fires *before* the visible area on
|
||
the next frame).
|
||
|
||
---
|
||
|
||
## Commit cadence
|
||
|
||
Roughly one commit per phase, with intermediate commits as
|
||
phases get big. Suggested commit messages:
|
||
|
||
```
|
||
Phase 0: repo skeleton + helloworld baseline
|
||
Phase 1: asset pipeline (Python script + 5 processed .bin files)
|
||
Phase 2: display the title screen (mmap, vic, screen swap)
|
||
Phase 3: score bar (pips + labels)
|
||
Phase 4: state machine skeleton (no audio, no random)
|
||
Phase 5: raster IRQ + frame timing
|
||
Phase 6: SID audio (5 cues)
|
||
Phase 7: DRAW screen + counter
|
||
Phase 8: polish and end-to-end test
|
||
```
|
||
|
||
Within a phase, break up by file: "Phase 2: add memmap_setup and
|
||
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.
|