From cb3a8c4a2eb6a40980768b0e7004ba2a813d0644 Mon Sep 17 00:00:00 2001 From: ballz Date: Fri, 17 Jul 2026 00:45:46 +0200 Subject: [PATCH] Add tasks.md: phased implementation plan --- tasks.md | 516 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 516 insertions(+) create mode 100644 tasks.md diff --git a/tasks.md b/tasks.md new file mode 100644 index 0000000..e8a206c --- /dev/null +++ b/tasks.md @@ -0,0 +1,516 @@ +# Tasks — Whack Hare! 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/`. + +> **Prerequisite correction to GAME.md**: I said the screens would be +> 320×200 standard hires. That's wrong for these images — they have +> well more than 2 colors per 8×8 cell. The correct target is +> **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` + `src/build.sh` produce + `src/build/helloworld.prg`. +- [x] ✅ `GAME.md` written. +- [x] ✅ Source artwork in `./source_images/`. +- [ ] ⏳ **Verify:** `cd src && ./build.sh -e` runs the hello-world + program in the oscar64 built-in emulator. + +**Done means:** `src/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 + +- [ ] Create `src/data/raw/` and `src/data/processed/`. +- [ ] Create `tools/convert_screens.py`. Inputs: a source PNG. + Outputs: two files — `.bin` (8000-byte multicolor bitmap) + and `.attr` (1000-byte screen memory with the foreground + color per cell). The script: + 1. Crops the source to 160×200 (or 320×200 with a 2× horizontal + scale, depending on what gives the best result — see + decision point below). + 2. For each 4×8 cell, quantizes to 4 colors from the fixed + 16-color C64 palette (black, white, red, cyan, purple, green, + blue, yellow, orange, brown, light red, dark grey, medium + grey, light green, light blue, light grey). + 3. Picks the most common color in the cell as the + "background" (one of $D021-$D024, encoded as 2 bits in the + screen memory high nibble). + 4. Picks the 2nd, 3rd, 4th most common as the foreground + (the cell's screen memory low nibble), and the two multicolor + registers (D022, D023) — encoded in screen memory high bits 4-5 + and 6-7. + 5. Writes the 2-bit-per-pixel bitmap (MSB-first within each 4-pixel + pair) to `.bin` and the 1000-cell attribute table to + `.attr`. +- [ ] **Decision point:** which of two crop strategies gives better + results? + - Strategy A: crop to 160×200 directly (loses horizontal detail). + - Strategy B: crop to 320×200 and 2× downscale to 160×200 with + area averaging. + Try both on `screen_waiting1.png` and pick whichever looks better + on the actual screen. +- [ ] Run the script on all 5 source images. Verify each output + is exactly 8000 + 1000 = 9000 bytes. +- [ ] Commit the script and the generated `.bin`/`.attr` files. + Generated files are checked in (not gitignored) so the build + doesn't depend on Python being installed. + +**Verify:** +- `python3 tools/convert_screens.py source_images/screen_title.png src/data/processed/title` + produces `title.bin` (8000 B) and `title.attr` (1000 B). +- `file src/data/processed/title.bin` reports "data". +- Opening one of the .bin files in a hex editor shows it's not all + zeros (sanity check that the script actually ran). + +**Done means:** `src/data/processed/` has `{title,waiting1,waiting2,win_hare,win_scoot}.{bin,attr}` 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 320×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 + +- [ ] Create `src/screens.h` and `src/screens.c` with the .bin + data as `const char ScreenTitleBin[8000]` etc., using + `#embed "../data/processed/title.bin"`. The .attr data similarly: + `const char ScreenTitleAttr[1000]`. +- [ ] Create `src/memmap_setup()` helper that calls + `mmap_trampoline()` then `mmap_set(MMAP_RAM)` (so the + $E000-$FFFF region is available for the bitmap), then `mmap_set(MMAP_NO_ROM)` + to also bank out CHAR ROM (so $D000 is I/O, not character data). + This gives us 8 KB free at $E000-$FFFF for the active bitmap. +- [ ] Create `src/show_screen(int n)` helper that takes a screen + ID, copies the right `.bin` to $E000-$FFFF, copies the `.attr` + to $D800-$DBE7, sets the VIC bank bits to point to the right + RAM, sets the bitmap base to $E000 via $D018 VM bits, sets + `vic_ctrl1` for BMM=1 (bit 5), `vic_ctrl2` for MCM=1 (bit 4), + CSEL=1 (40 columns), and `vic.color_back` ($D021) to black. +- [ ] Modify `src/helloworld.c` (or replace with a new `src/main.c`) + to call `memmap_setup()`, then `show_screen(SCREEN_TITLE)`, + then poll joystick port 1 — when fire is pressed, restore + `mmap_set(MMAP_ROM)` and exit. +- [ ] Add a joystick-read helper `input_fire(int port)` in + `src/input.c` / `src/input.h`. Returns 1 if fire is currently + pressed (active low: `(PEEK(0xDC00+port) & 0x10) == 0`). + +**Verify:** +- `cd src && ./build.sh -e` displays the title screen in the + emulator for 5 seconds (or until fire is pressed) then exits. +- The .map file shows our code is in $0900-$1100-ish, well within + the 38 KB main region. +- The .prg loads and runs in `x64` (VICE) on real timings. + +**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 (the +top 8 pixels of the hires bitmap, which is one character row in +character mode). Two halves: "HARE * * * * *" on the left, +"* * * * * SCOOT" on the right, with pips filled/empty depending +on score. + +### Tasks + +- [ ] Create `src/score.h` and `src/score.c` with a 5-element + `byte score_p1`, `byte score_p2`, and a `score_render()` function + that draws the pips directly into the top of the bitmap + (the first 8×320 = 320 bytes of the active screen at $E000). +- [ ] Pip rendering: a filled pip is a 6×6 black block with a + 1-pixel white border. An empty pip is just the border. + Center the 5 pips in the left half (col 0-159) and right half + (col 160-319). +- [ ] Add a small text "HARE" / "SCOOT" label above each pip group + (centered, in the same 8-row band). Implement with a 4-character + custom font (just H, A, R, E, S, C, O, T — 8 chars total, 1 KB + char ROM, but only 8 bytes per character needed so 64 bytes + total; we can keep it in a `char` array). +- [ ] Initialize scores to 0 in `main()`. Call `score_render()` + after each `show_screen()`. + +**Verify:** +- Title screen now shows "HARE 0 0 SCOOT" (or pip equivalent) + at the top. +- Changing `score_p1 = 3` in main() shows 3 filled pips on the + left (manually verify by hardcoding the score and rebuilding). + +**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 (not random) +durations, no score updates. Just the screens in the right order +at the right times. + +### Tasks + +- [ ] Create `src/game.h` and `src/game.c` with an enum: + `STATE_TITLE, STATE_READY, STATE_WAIT, STATE_DRAW, + STATE_WIN_P1, STATE_WIN_P2, STATE_GAMEOVER`. A `byte state` + global, and a `game_init()`, `game_step()` pair. +- [ ] Each state has an `enter` action (set the screen, start any + audio, reset a frame counter) and a `step` action (check inputs, + advance the frame counter, transition to the next state). +- [ ] Without an IRQ yet, the main loop polls joysticks and + advances state. This is fine for testing the flow. +- [ ] TITLE: loop, flash "PRESS FIRE" text at 25 Hz, exit when + both ports have fire pressed simultaneously (de-bounced: both + must be pressed within ~8 frames of each other; otherwise wait + for both to release and re-press). +- [ ] READY: show `screen_waiting1`, count 60 frames, → WAIT. +- [ ] WAIT: show `screen_waiting2`, count 100 frames (later: + random 100-250), → DRAW. +- [ ] DRAW: show a white screen (just fill the bitmap with 0s + and set $D021 to white). No counter yet, no audio. Just + watch for fire on either port; first to fire → WIN_P1 or + WIN_P2 respectively. If no fire for 500 frames → back to + TITLE (the "fault" case). +- [ ] WIN_P1 / WIN_P2: show the appropriate win screen, count + 100 frames, → READY (or → GAMEOVER if score == 5). +- [ ] GAMEOVER: show title screen, count 300 frames, → TITLE + (with scores reset to 0/0). +- [ ] Update `score_render()` to be called on entry to each state. + +**Verify:** +- Run the program: it shows the title with flashing text. Press + both fire buttons → READY screen for 1 sec → WAIT screen for + ~1.6 sec → white DRAW screen. Press fire on port 1 → win_hare + screen for 2 sec → READY again (loop). 5 wins → title reset. +- The state transitions are visible and predictable. + +**Done means:** the game loops. No audio, no fancy DRAW screen, no +random WAIT duration, no score update on win — 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. Every screen, every animation, every input poll is now +synchronized to a stable frame tick. Also implement the random +WAIT duration using the SID's $D41B oscillator register as a +random source. + +### Tasks + +- [ ] Create `src/rasterirq.c` / `src/rasterirq.h` with a single + RIRQ at line 311 (PAL stable line). The handler: + - Increments a global `frame_count` (16-bit, 50 Hz). + - Calls `game_step()`. + - Reads both joysticks, updates the input state. + - Calls any per-frame "redraw" functions needed (counter, + flashing text). + - Re-arms the IRQ for the next frame. +- [ ] Replace the busy-wait frame counter in `game_step()` with + comparisons against `frame_count` and a per-state + `enter_frame` timestamp. +- [ ] Use `PEEK(0xD41B)` (SID oscillator 3) for the WAIT random + duration: 100 + (PEEK(0xD41B) % 150) frames, sampled at + READY enter. +- [ ] Mask CIA 1 and CIA 2 IRQs in the IRQ setup so the + jiffy-clock handler doesn't fire nested. (The oscar64 + `rasterirq` library does this for us, but doing it by hand + teaches us what's happening.) +- [ ] Add a "fault counter" — if neither player fires in 500 + frames in DRAW state, go back to TITLE with a short stinger + sound (a low square wave burst, ~0.2 sec) and no point awarded. + +**Verify:** +- The game still works in the emulator. +- WAIT duration varies visibly across multiple rounds (use the + oscar64 `-e` emulator's deterministic timing: even with + randomness, the 50 Hz tick is rock-solid). +- Press fire during WAIT (cheating) — nothing happens, we + ignore inputs in WAIT state. (Add this as an explicit + assertion in the test plan.) + +**Done means:** the game has a proper 50 Hz frame tick. The +"feel" of the game (the timing of the READY → WAIT → DRAW +transitions) is now under our control via frame counts rather +than wall-clock waits. + +--- + +## Phase 6 — SID audio + +**Goal:** implement the 5 audio cues from GAME.md §6. The game +becomes audible. + +### Tasks + +- [ ] Create `src/notes.h` with a frequency lookup table for one + octave of equal-tempered notes (C2..C7) as 16-bit SID + frequency values. Or just define the few specific notes we + need by hand: A4, A5, C2, G1, C5, E5, G5, C6, G4. +- [ ] Create `src/audio.h` and `src/audio.c` with: + - `audio_init()` — set master volume $0F, no filter. + - `audio_state_enter(int state)` — called from each state's + `enter` action. Sets up the SID voices for the cue + appropriate to that state. + - `audio_state_step(int state)` — called from the raster + IRQ. Advances notes per the per-state schedule. + - `audio_stop()` — silence all 3 voices. +- [ ] Implement the 5 cues per the table in GAME.md §6. + Start with the simplest: the DRAW stab (voice 3 noise, + attack=0, decay=1, gated for 4 frames). Add the others + one at a time. +- [ ] The WAIT suspense loop is the trickiest — implement it + as a fixed schedule (every 16 jiffies, retrigger voice 1 + with the next note). Keep voice 2 doing a quieter + arpeggio offset by 8 jiffies. +- [ ] Make sure the audio doesn't "leak" between states: + when transitioning out of WAIT, gate off both voices + before starting the DRAW stab. + +**Verify:** +- Each state has its cue, in isolation (by hardcoding the + state in main and rebuilding): + - TITLE: silent (or very subtle). + - READY: ping-ping. + - WAIT: pulse-pulse-pulse-pulse (about 3-4 pulses per + second). + - DRAW: staaaab. + - WIN: arpeggio up. + - GAMEOVER: longer fanfare. +- The cues are recognizable and feel right (timing, volume). + +**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, and the per-frame timing is +tied to the raster IRQ so the digits never flicker. + +### Tasks + +- [ ] Add a `draw_render_counter(int value)` function that writes + 3 digit characters to the screen memory (which is in character + mode, not multicolor, during the DRAW state — so we'll need + a "switch VIC to hires char mode briefly" helper or just write + the digits directly into the bitmap as 32×32 pixel blocks). +- [ ] Decide on the implementation: + - Option A: switch to hires char mode for DRAW, use the + standard 8×8 ROM font, scale 4×. Simpler code. + - Option B: keep multicolor bitmap mode, render the digits + as a 4-color 4×8 cell layout. More code, looks more + consistent with the rest of the game. + - Start with Option A (simpler, ship faster). +- [ ] On entering DRAW, fill the bitmap with 0s and set + $D021 (background) to white. Then call + `draw_render_counter(0)` once. +- [ ] Each frame, increment the counter (cap at 999) and call + `draw_render_counter(counter)`. +- [ ] Add a "flash" effect: for the first 2 frames of DRAW, + invert the colors (white background, black border, screen + briefly all black). Or simpler: the border $D020 is set + to white for the first 4 frames, then back to black. + +**Verify:** +- When DRAW starts, the screen goes white with a brief flash. +- A 3-digit counter starts at 001 and ticks up by 1 each frame. +- The counter caps at 999 and stays there. +- The screen doesn't flicker (counter updates happen in the + raster IRQ, on the same line each frame). + +**Done means:** the DRAW screen looks right and feels right. +This is the most visually exciting moment of the game, so it +matters. + +--- + +## Phase 8 — Polish and end-to-end test + +**Goal:** the game is fully playable from power-on to match-end, +with no rough edges. We also do the real-hardware test. + +### Tasks + +- [ ] On TITLE, flash "PRESS FIRE" at 1 Hz (50 on, 50 off). +- [ ] On GAMEOVER, show a "HARE WINS!" or "SCOOT WINS!" banner + in place of the flashing prompt. (Same custom font as the + score bar; needs 6 more chars: W, I, N, !, S, space — some + are already in there.) +- [ ] Add a brief stinger sound (0.1 sec) on each state + transition. Just a quick low square wave. +- [ ] Make sure scores are reset on entering GAMEOVER → TITLE. +- [ ] Run the full game in VICE (`x64src/build/whack_hare.prg`) + multiple times, including: + - P1 wins 5 in a row (cheat test: hold fire on port 1 the + whole DRAW). + - P2 wins 5 in a row. + - Tie game: both fire on the same frame (impossible to test + deliberately, but log it if it happens). + - "Fault" case: nobody fires for 10 sec. +- [ ] Build with `-O3` and verify the .prg still works + (optimization can change timing subtly). +- [ ] If we have a real C64 or a Turbo Everdrive, test on + real hardware. Otherwise document that we tested in VICE + cycle-exact mode (`x64sc`). +- [ ] Strip `-g` from the release build. Add a `-O3` build + target to build.sh. +- [ ] Final pass: review the .map file, check no section is + larger than expected, check no RAM region is over-allocated. + +**Verify:** +- The game plays end-to-end on real timings. +- A typical match takes 30-60 seconds. +- No crashes, no hangs, no leftover sound. +- The .prg is < 32 KB and fits in the C64's BASIC area + (i.e. the standard 202-block load via `LOAD"*",8,1`). + +**Done means:** the game ships. + +--- + +## Phase 9 — (Optional) extra features + +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. + +--- + +## 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".