Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ build-profile-timex/
build-bench/
__pycache__/
build-bench-std/
.claude/worktrees/
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ with `pkill -f 'zesarux --configfile'` first):

(Timex: `--machine TC2068 --enabletimexvideo` and the timex TAP.)

On a headless host add `--vo null --ao null --audiovolume 0`. Without
`--vo null` the Cocoa build blocks before it opens the ZRCP port, so the
emulator sits at 0 % CPU with nothing listening and every tool times out
waiting for it. The audio flags matter whenever the host has a speaker: the
game starts its AY effects as soon as it runs.

### Autopilot bench builds

Unattended measurement uses the AUTOPILOT define: invulnerable plane,
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,24 @@ both TAPs to every tagged release.
GitHub Pages, embedded in the vendored JSSpeccy 3 emulator
(`site/`, `.github/workflows/pages.yml`), linked from the README.

### Fixed

- Shots no longer erase the river banks. A two-pixel projectile was drawn by
storing its mask over the whole screen byte and cleaned up by writing plain
water, both assuming the byte was entirely river. Because a bullet keeps the
X it was fired at while the river narrows above it, and because its collision
test only asks whether the two lit pixels meet land, a byte holding both the
shot and bank pixels lost its land. On a straight bank the notch was
permanent. Projectiles and the splash now compose against live terrain and
their cleanup restores real world bytes.
- The tank splash lands on its target instead of eight pixels to the right of
it. Its aim point is a centre but was consumed as a left edge, which put the
sprite's second byte on the right bank edge column.
- Ships and the helicopter no longer spawn partly on land. All three asked for
a safe position using a scanline above the playfield, because raising their
active flag overwrote the row they meant to sample; ten percent of ship
spawns were affected.

## [0.3.0] - 2026-07-26

### Added
Expand Down
413 changes: 253 additions & 160 deletions docs/TODO.md

Large diffs are not rendered by default.

18 changes: 14 additions & 4 deletions docs/renderer.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,20 @@ scanlines, so the renderer updates:
- 19 scanlines at 1 px per frame,
- 38 scanlines at 2 px per frame.

On every affected scanline it normally replays only the precomputed bytes whose
terrain value changed; complex fork transitions fall back to the bounded edge
renderer. The full 6144-byte bitmap is rendered only during startup and after
`R`.
On every affected scanline it replays only the precomputed bytes whose terrain
value changed. The full 6144-byte bitmap is rendered only during startup and
after `R`.

The delta list holds at most 16 byte pairs, and a block that would need more
is marked `count=255` so the scanline falls back to the complete edge renderer
(`render_v3_row_indexed`). That fallback is a safety net rather than a working
case: a bank edge moves at most four pixels per block and an island opens one
byte wide and grows or tapers one byte per side per step, so consecutive blocks
never differ in enough bytes to overflow. `render_v3_row_indexed` therefore has
only one live caller - bridge repair - and because every block of a bridge zone
is generated without an island, its island-handling half never runs either.
Both are kept so that a future wider terrain feature degrades correctly instead
of drawing a truncated delta; neither is worth optimizing.

## Colour, bridge, and sprites

Expand Down
76 changes: 25 additions & 51 deletions src/course_renderer.asm
Original file line number Diff line number Diff line change
Expand Up @@ -284,8 +284,8 @@ course_right_x_ready:
rebuild_block_bitmap_row:
; Materialize the complete 32-byte world row for the newly generated
; course block. Its geometry is five contiguous runs, so emit those runs
; directly instead of asking get_course_background_byte_indexed thirty-two
; times. Runtime mixed-terrain sprite composition still sees the identical
; directly instead of classifying each of the thirty-two columns on its
; own. Runtime mixed-terrain sprite composition still sees the identical
; materialized row.
ld a,(course_block_head)
ld (block_bitmap_build_index),a
Expand Down Expand Up @@ -415,7 +415,14 @@ rebuild_block_delta_next:
jr nz,rebuild_block_delta_byte
jr store_block_delta_count
rebuild_block_delta_overflow:
ld a,255 ; rare complex block uses old renderer
; Safety net, not a working case. The current generator cannot fill 16
; pairs: each bank edge moves at most four pixels per block, so it dirties
; a couple of bytes, and an island opens at one byte wide and grows or
; tapers by one byte per side per step (fork_left_offsets / fork_widths in
; main.asm). A block therefore differs from its predecessor in a handful of
; bytes. Keep the marker so a future wider feature degrades into the
; complete edge renderer instead of rendering a truncated delta.
ld a,255
ld (block_delta_build_count),a
store_block_delta_count:
; The count page holds 32 live entries mirrored eight times, so the dirty
Expand All @@ -435,45 +442,6 @@ store_block_delta_mirror:
djnz store_block_delta_mirror
ret

get_course_background_byte_indexed:
; Input A=byte column, L=course block index. Output A=world byte.
ld b,a
ld h,HIGH(block_left_col)
cp (hl)
jr c,indexed_background_land
jr nz,indexed_background_check_right
ld h,HIGH(block_left_mask)
ld a,(hl)
ret
indexed_background_check_right:
ld a,b
ld h,HIGH(block_right_col)
cp (hl)
jr c,indexed_background_check_island
jr nz,indexed_background_land
ld h,HIGH(block_right_mask)
ld a,(hl)
ret
indexed_background_check_island:
ld h,HIGH(block_island_left)
ld a,(hl)
cp 255
jr z,indexed_background_water
ld c,a
ld a,b
cp c
jr c,indexed_background_water
ld h,HIGH(block_island_right)
cp (hl)
jr c,indexed_background_land
jr z,indexed_background_land
indexed_background_water:
xor a
ret
indexed_background_land:
ld a,255
ret

block_bitmap_address:
; Input A=block index 0..31, C=column 0..31. The 1KB cache is four
; consecutive aligned pages, eight complete course rows per page.
Expand Down Expand Up @@ -922,8 +890,10 @@ dirty_delta_replay:
jr dirty_row_advance

dirty_row_fallback:
; Reconstruct the row Y and block index which the fast loop keeps
; implicit, then run the full edge renderer with the registers saved.
; Cold path: only a count=255 block reaches it, which the generator never
; produces (see rebuild_block_delta_overflow). Reconstruct the row Y and
; block index which the fast loop keeps implicit, then run the full edge
; renderer with the registers saved.
ld a,(dirty_rows_remaining)
ld b,a
ld a,19
Expand Down Expand Up @@ -975,6 +945,12 @@ render_v3_row_indexed:
; Dirty rows advance by exactly eight scanlines, so their circular block
; index is maintained by the caller. Bridge repair still enters above and
; calculates the first index normally.
;
; This routine has exactly two callers, and neither is the scroll pass:
; dirty_row_fallback, which the generator never triggers, and bridge repair
; via render_v3_row. Scrolling replays precomputed deltas in
; dirty_delta_replay instead. So despite the dirty_ label prefix below,
; nothing here is on the hot path - correctness matters, speed does not.
ld a,(dirty_y)
cp 16
ret c
Expand Down Expand Up @@ -1030,6 +1006,11 @@ render_v3_row_indexed:
; Compare the old and new island intervals. Unchanged plateaus cost no
; writes; changing tapers touch only bytes exposed at either edge instead
; of clearing and repainting the whole overlapping island.
;
; In practice this comparison always finds no island at all: the only live
; caller is bridge repair, and a bridge zone is generated with island=255
; on every block. It is kept because the routine is also the declared
; fallback for a block delta that overflows.
ld a,(row_block_index)
dec a
and 31
Expand Down Expand Up @@ -1249,13 +1230,6 @@ get_bounds_for_y:
ld e,(hl)
ret

calc_river_center_col:
call get_bounds_for_y
ld a,d
add a,e
srl a
ret

get_pixel_lane_bounds:
; Input A=Y, C=current pixel X. Output D=min X and E=max X for the
; 16-pixel object's current water lane. During a fork the island changes
Expand Down
9 changes: 9 additions & 0 deletions src/entities.asm
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ spawn_ship0:
ld (ship0_y),a
ld a,1
ld (ship0_active),a
ld a,(ship0_y) ; the active flag above clobbered the Y
call calc_safe_river_x_wide
ld (ship0_x),a
ret
Expand Down Expand Up @@ -169,6 +170,7 @@ spawn_ship1:
ld (ship1_y),a
ld a,1
ld (ship1_active),a
ld a,(ship1_y) ; the active flag above clobbered the Y
call calc_safe_river_x_wide
ld (ship1_x),a
ld a,1
Expand Down Expand Up @@ -558,6 +560,7 @@ spawn_helicopter:
ld (helicopter_y),a
ld a,1
ld (helicopter_active),a
ld a,(helicopter_y) ; the active flag above clobbered the Y
call calc_safe_river_x
ld (helicopter_x),a
ld a,(helicopter_move)
Expand Down Expand Up @@ -1181,7 +1184,13 @@ move_tank_shell_left:
ret

land_tank_shell:
; tank_shell_target_x is a centre: it is clamped against the lane exactly
; like the jet's own centre. tank_shell_x is a left edge for every consumer,
; the splash draw and its cleanup rect included, so convert once here.
; Without this the 16-pixel splash sat eight pixels right of its landing
; point and its second byte covered the right bank edge.
ld a,(tank_shell_target_x)
sub 8
ld (tank_shell_x),a
call start_ay_splash
ld a,10
Expand Down
23 changes: 0 additions & 23 deletions src/render_timex.asm
Original file line number Diff line number Diff line change
Expand Up @@ -165,29 +165,6 @@ timex_advance_object_third_fast:
inc h
ret

timex_next_attribute_row:
; Input/output HL=the same byte column on adjacent Timex attribute lines.
; Spectrum display memory advances H inside an 8-line character band;
; crossing its last line advances L by 32 and folds H to the next band.
ld a,h
and 7
cp 7
jr z,timex_next_attribute_band
inc h
ret
timex_next_attribute_band:
ld a,l
add a,32
ld l,a
jr c,timex_next_attribute_third
ld a,h
sub 7
ld h,a
ret
timex_next_attribute_third:
inc h
ret

prepare_timex_fuel_geometry:
ld a,1
ld (object_attr_width),a
Expand Down
48 changes: 34 additions & 14 deletions src/sprite_renderer.asm
Original file line number Diff line number Diff line change
Expand Up @@ -772,15 +772,11 @@ prepare_transition_new_wide_ready:
ret

prepare_transition_old_projectile_x:
; A is the actual two-pixel left edge.
; A is the actual two-pixel left edge. The pair spills into the following
; byte only at pixel offset 7, so this is the same test as
; prepare_transition_new_projectile_x below, writing the old fields.
ld c,a
and 7
ld a,1
jr nz,prepare_transition_old_projectile_check
jr prepare_transition_old_projectile_ready
prepare_transition_old_projectile_check:
ld a,c
and 7
cp 7
ld a,1
jr nz,prepare_transition_old_projectile_ready
Expand Down Expand Up @@ -885,7 +881,7 @@ draw_current_splash_table_ready:
ld c,a
ld a,(tank_shell_y)
ld b,6
jp write_water_sprite_2xn
jp write_world_sprite_2xn ; the splash may straddle a bank byte

draw_current_balloon_direct:
ld a,(balloon_active)
Expand Down Expand Up @@ -1274,7 +1270,7 @@ transition_bullet_old_ready:
transition_bullet_new_ready:
ld a,4
ld (transition_height),a
xor a
ld a,1 ; restore real terrain, not blanket water
ld (transition_background),a
call cleanup_resident_sprite_delta
jp draw_current_bullet_direct
Expand Down Expand Up @@ -1959,7 +1955,7 @@ transition_shell_direct:
ld (transition_old_y),a
ld a,(tank_shell_y)
ld (transition_new_y),a
xor a
ld a,1 ; restore real terrain, not blanket water
ld (transition_background),a

; Projectile -> splash changes both height and representation. Restore the
Expand Down Expand Up @@ -2017,17 +2013,26 @@ restore_flying_shell_clip:
ld a,b
cp PLAYFIELD_BOTTOM
ret nc
; Restore true world bytes, not plain water: the shell's byte can hold bank
; pixels even though its two lit pixels never do.
ld a,(transition_old_col)
ld c,a
ld a,b
push bc ; the terrain query clobbers B and C
call load_world_background_triplet
pop bc
ld a,b
call calc_screen_line_addr
ld a,(transition_old_col)
add a,l
ld l,a
xor a
ld a,(world_background_byte_0)
ld (hl),a
ld a,(transition_old_width)
cp 2
ret c
inc l
xor a
ld a,(world_background_byte_1)
ld (hl),a
ret

Expand Down Expand Up @@ -3104,8 +3109,12 @@ bridge_tank_direct_next_row:
ret

write_water_projectile_2xn:
; Input A=Y, C=pixel X, B=height. Store the two-pixel mask directly over
; guaranteed water; unlike XOR this cannot remove an already visible shot.
; Input A=Y, C=pixel X, B=height. A two-pixel shot is not guaranteed to sit
; over a whole water byte: bullet_x is latched when the shot is fired and
; the river keeps narrowing above it, and the collision test only asks
; whether the two lit pixels meet land. Storing the bare mask therefore used
; to erase whatever bank pixels shared the byte, permanently on a straight
; section, so each byte is composed against fresh world geometry instead.
ld (world_write_y),a
ld a,b
ld (world_write_rows),a
Expand All @@ -3130,15 +3139,26 @@ write_water_projectile_row:
ld a,(world_write_y)
cp PLAYFIELD_BOTTOM
ret nc
ld a,(world_write_col)
ld c,a
ld a,(world_write_y)
call load_world_background_triplet
ld a,(world_write_y)
call calc_screen_line_addr
ld a,(world_write_col)
add a,l
ld l,a
ld a,(world_background_byte_0)
ld b,a
ld a,(world_write_byte_0)
xor b ; a shot over land reads as a water hole
ld (hl),a
ld a,(world_write_byte_1)
or a
jr z,write_water_projectile_skip_spill
ld b,a
ld a,(world_background_byte_1)
xor b
inc l
ld (hl),a
write_water_projectile_skip_spill:
Expand Down
Loading