From bf42d9551feab6005b081ebc6519884b56e0e6dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Sun, 26 Jul 2026 21:22:03 +0200 Subject: [PATCH] Blow a destroyed span apart in stages instead of erasing it A destroyed bridge used to disappear between one frame and the next. It now goes in chunks: the shot punches a two-byte hole where the player aimed, wide enough to fly through, and the hole eats outward two byte columns per side every eight frames. Each stage restarts the impact explosion and its AY burst, alternating ends, so the span goes with a rhythm of separate blasts rather than a smooth fade. Both numbers are BRIDGE_CRUMBLE_CHUNK and BRIDGE_CRUMBLE_PAUSE in tools/build.py, so the rhythm can be retuned with DEFINES= alone. The span stays bridge_active for the whole sequence. That is the part which matters: nothing else repaints the wreck, so a span removed from the model freezes on the screen rows it happened to occupy while the world scrolls out from under it. Left active, the ordinary bridge machinery keeps moving the band, filling the rows entering at its bottom, restoring the rows leaving its top and repainting its colour cells; only the hole columns are excluded, and they are excluded everywhere at once. The world model reports river for them, so the plane flying through composes and restores against water instead of punching road back into the gap; the band writer steps over them; and the colour pass gives them water while the surviving stumps keep road colour. That last point also removes the green flash. Colouring the whole span as water at the moment of the hit left 0xff road bytes under water paper, and a lit pixel takes the INK, so the abandoned road glared solid green until a column happened to be redrawn. Colour now never runs ahead of the bitmap. Lethality is now separate from existence: bridge_lethal is cleared by the hit, so the wreck kills nothing and stops being a target while it crumbles. Player and projectile collision test that flag instead of bridge_active. When the hole reaches both ends the bridge hands over to the plain destroyed road, whose per-frame cost is unchanged. Verified on both builds in ZEsarUX over several natural destructions: the hole widens in two-column steps from the hit outward, bridge_y increases monotonically throughout (the wreck scrolls), the plane is never lethal to itself against it, and the handover leaves bridge_active=0 with destroyed_road_active=1. Screenshots confirm red stumps over clean river with no green. Trace profiler over 30s of autopilot play: 16.6% frame overruns against 16.1% on main, which is scene noise between two runs. Two tools needed repair to get those measurements. The profiler's cpu-history commands are refused while an emulator menu is open, which under --vo null cannot be dismissed from the keyboard; it silently fell back to a 1M-entry buffer and then failed to drain. It now closes menus over the protocol and retries every step. The screenshot tool read five state bytes as one block and was quietly shifted by the new bridge_lethal byte, so it now reads each symbol by name. AGENTS.md records these traps along with the rest. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 27 ++- CHANGELOG.md | 8 + docs/gameplay.md | 37 ++++- src/entities.asm | 281 +++++++++++++++++++++++++------- src/sprite_renderer.asm | 209 ++++++++++++++++++++++-- src/state.asm | 15 ++ tools/build.py | 4 + tools/zrcp_bridge_screenshot.py | 9 +- tools/zrcp_tail_profiler.py | 38 +++-- 9 files changed, 525 insertions(+), 103 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 51b63fa..536b370 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,11 +88,28 @@ python3 tools/zrcp_tail_profiler.py build-bench-std/attribute-raid.map \ 30 /tmp/profile.txt ``` -`PLAY_SECONDS 0` only drains an already recorded history. Pitfalls: run -one profiling cycle per emulator launch (toggling cpu-history can pop the -emulator menu); do not sample with `get-registers` polling (it aliases to -the command-service point); entry counts are not T-states — idle frames -have MORE entries, not fewer. +`PLAY_SECONDS 0` only drains an already recorded history. + +Pitfalls, all of them learned the hard way: + +- Run **one profiling cycle per emulator launch**. A second cycle on the + same instance reads whatever state the first one left behind, which shows + up as a trace that is 100% ROM. +- **Wait until the game is actually playing before starting the profiler.** + Every cpu-history command briefly enters cpu-step mode, which the emulator + refuses while a menu is open; the tool now clears that with + `close-all-menus`, and doing so *cancels a tape load in progress*, leaving + the machine in BASIC. Poll a game symbol (`player_x` changing) first. +- **`write-memory` silently drops the emulator into cpu-step.** Follow every + poke with `exit-cpu-step` or the machine freezes and nothing else happens. +- Reading a multi-byte value one byte at a time straddles the frame that + changes it and invents intermediate states. Read adjacent bytes in a single + `read-memory`. +- Do not sample with `get-registers` polling (it aliases to the + command-service point). +- Entry counts are not T-states — idle frames have MORE entries, not fewer. +- Two builds never play the same scene, so overrun percentages compare only + loosely; a difference of a point or so is scene noise, not a regression. ### tools/zrcp_bridge_screenshot.py — frozen visual verification diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ac5a20..b584785 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ both TAPs to every tagged release. ### Added +- Bridges crumble instead of vanishing. The shot punches a hole the plane can + fly through, and the hole then eats outward from it two byte columns at a + time, one chunk every eight frames, each with its own explosion and blast so + the span goes with a rhythm rather than a fade. The wreck stays part of the + bridge model for the whole sequence, so it scrolls down with the world like + the road it still is, and the surviving stumps keep their road colour while + only the blown columns turn to river. + - Play-in-browser page: every push to `main` deploys the current 128K TAP to GitHub Pages, embedded in the vendored JSSpeccy 3 emulator (`site/`, `.github/workflows/pages.yml`), linked from the README. diff --git a/docs/gameplay.md b/docs/gameplay.md index 2417b25..21a2086 100644 --- a/docs/gameplay.md +++ b/docs/gameplay.md @@ -83,11 +83,34 @@ Bridge placement alternates between the naturally reached river width and a deliberately narrow section (at most roughly 112 pixels of water), so not every crossing is generated over a broad river. -Destroying a bridge clears all 16 affected bitmap scanlines before rebuilding -the complete banks, island, and river; the normal dirty renderer updates only -bank edges and therefore cannot reconstruct a row which has been cleared -completely. The brown centre span becomes blue water, while the road -approaches remain on both banks and keep scrolling down with the world - as a -white band on Timex with no further bitmap work, and with their two black -edge lines maintained per frame on the standard build. +Destroying a bridge does not remove the span in one frame. The shot punches a +two-byte hole where the player aimed, wide enough to fly through, and the hole +then widens outward by `BRIDGE_CRUMBLE_CHUNK` byte columns on each side every +`BRIDGE_CRUMBLE_PAUSE` frames (defaults: two columns, eight frames - a 16x16 +pixel chunk per side, the band being two character rows tall). Each stage +restarts the impact explosion and its AY burst, alternating ends, so the span +goes with a rhythm of separate blasts instead of a smooth dissolve. Both +constants live in `tools/build.py` and can be overridden with `DEFINES=` to +retune the rhythm without touching the source. + +The span stays `bridge_active` for the whole crumble, and that is what keeps +the wreck scrolling with the world: the ordinary bridge machinery still moves +the band, fills the rows entering at its bottom, restores the rows leaving its +top, and repaints its colour cells. Only the hole columns are excluded, and +they are excluded consistently - the world model reports river for them, the +band writer steps over them, and the colour pass gives them water while the +surviving road keeps its own attribute. Colouring the whole span as water at +the moment of the hit is what used to make the abandoned road glare solid +green: an `0xff` road byte under water paper shows its green INK. A hole column +is rewritten from the world model rather than zero-filled, because the FUEL +depot belongs to that model and may share those columns. + +Lethality is separate from existence. `bridge_lethal` is cleared by the hit, so +the wreck kills nothing and is no longer a target while it crumbles; +`check_player_background_pixels` tests that flag rather than `bridge_active`. +Once the hole reaches both ends of the span the bridge hands over to the plain +destroyed road, which needs no per-frame bitmap work: the brown centre is +already blue water, while the road approaches remain on both banks and keep +scrolling down with the world - as a white band on Timex, and with their two +black edge lines maintained per frame on the standard build. diff --git a/src/entities.asm b/src/entities.asm index 023f7c7..079c8be 100644 --- a/src/entities.asm +++ b/src/entities.asm @@ -1240,6 +1240,7 @@ tank_on_right: ret update_bridge: + call update_bridge_crumble call update_destroyed_road ld a,(bridge_spawn_pending) or a @@ -1248,8 +1249,10 @@ update_bridge: ld (bridge_spawn_pending),a ld a,1 ld (bridge_active),a + ld (bridge_lethal),a xor a ld (destroyed_road_active),a + ld (bridge_crumble_active),a ; Anchor the band on the block boundary: the newest block occupies rows ; 0..phase, so the two latched flat blocks begin at exactly phase+1. ; On the latched byte-aligned banks block_right_col is the first LAND @@ -1331,6 +1334,8 @@ bridge_top_restored: call bridge_clear_road_attributes xor a ld (bridge_active),a + ld (bridge_lethal),a + ld (bridge_crumble_active),a ld (bridge_tank_mode),a ld (bridge_tank_active),a ret @@ -1369,6 +1374,9 @@ update_destroyed_road: ld a,(destroyed_road_active) or a ret z + ld a,(bridge_active) + or a + ret nz ; still crumbling: the bridge path scrolls it ld a,(speed_pixels) or a ret z @@ -1462,36 +1470,6 @@ standard_road_line_segment: ret #endif -bridge_fill_full_bitmap_row: - ; Input A=Y, C=byte. Road rows are inside a bridge band, so land plus - ; bridge makes the complete 256-pixel scanline solid. - cp 16 - ret c - cp PLAYFIELD_BOTTOM - ret nc - call calc_screen_line_addr - ld b,4 - ld a,c -bridge_full_bitmap_byte: - ld (hl),a - inc l - ld (hl),a - inc l - ld (hl),a - inc l - ld (hl),a - inc l - ld (hl),a - inc l - ld (hl),a - inc l - ld (hl),a - inc l - ld (hl),a - inc l - djnz bridge_full_bitmap_byte - ret - update_bullet: ld a,(fire_pending) or a @@ -1540,9 +1518,9 @@ move_bullet_up: ; Actor sprites are absent from the bitmap during collision, so the ; crossing aircraft is tested explicitly above. Bridge geometry is also ; explicit because a background hit on it must award points and destroy it. - ld a,(bridge_active) + ld a,(bridge_lethal) or a - jp z,bullet_checks_destroyed_road + jp z,bullet_checks_destroyed_road ; a wreck mid-crumble is no longer a target ld a,(bullet_y) add a,10 ; swept projectile bottom ld b,a @@ -1960,39 +1938,220 @@ destroy_bridge: call add_score_100 call add_score_100 call destroy_bridge_tank_bonus - call bridge_paint_destroyed_road_attributes - ld a,(bridge_y) - ld (bridge_restore_y),a - ld a,16 - ld (bridge_restore_rows),a -destroy_bridge_restore: - ld a,(bridge_restore_rows) - or a - jr z,destroy_bridge_done - ld a,(bridge_restore_y) - cp PLAYFIELD_BOTTOM - jr nc,destroy_bridge_done - ; render_v3_row normally assumes that all bank interiors are already set. - ; The solid span violates that invariant, so clear the complete scanline - ; and use the deliberately slower full-row world reconstruction here. - ld c,0 - call bridge_fill_full_bitmap_row - ld a,(bridge_restore_y) - call render_full_world_row - ld a,(bridge_restore_y) - inc a - ld (bridge_restore_y),a - ld a,(bridge_restore_rows) - dec a - ld (bridge_restore_rows),a - jr destroy_bridge_restore -destroy_bridge_done: + ; The span stops killing anything at the moment of the hit, but it stays + ; bridge_active: the wreck is still drawn, so it must keep scrolling through + ; the ordinary bridge machinery instead of freezing on the screen rows it + ; happened to occupy. Only the hole columns leave the road model. xor a - ld (bridge_active),a + ld (bridge_lethal),a ld a,1 ld (destroyed_road_active),a + call setup_bridge_crumble jp preserve_road_tank_after_bridge +setup_bridge_crumble: + ; Only the span crumbles. On the latched flush banks of a bridge zone it is + ; exactly the water columns, so the land approaches - and the black road + ; edge lines on band rows 1 and 14 - are left alone throughout. + ld a,(bridge_col) + ld (bridge_crumble_min),a + ld b,a + ld a,(bridge_width) + add a,b + dec a + ld (bridge_crumble_max),a + + ; Open the first hole at the column the shot went through, clamped into the + ; span, so the plane can always fly on through where the player aimed. + ld a,(bullet_x) + add a,7 + srl a + srl a + srl a + ld b,a + ld a,(bridge_crumble_min) + cp b + jr c,crumble_hit_above_min + ld b,a ; hit at or left of the span: clamp to min + jr crumble_hit_ready +crumble_hit_above_min: + ld a,(bridge_crumble_max) + cp b + jr nc,crumble_hit_ready + ld b,a ; hit right of the span: clamp to max +crumble_hit_ready: + ld a,b + ld (bridge_hole_left),a + ld (bridge_crumble_col),a + inc a ; a two-byte opening unless the span ends + ld b,a + ld a,(bridge_crumble_max) + cp b + jr nc,crumble_hole_right_ready + ld b,a +crumble_hole_right_ready: + ld a,b + ld (bridge_hole_right),a + ld (bridge_crumble_col_last),a + ld a,1 + ld (bridge_crumble_active),a + xor a + ld (bridge_crumble_phase),a + ld a,BRIDGE_CRUMBLE_PAUSE + ld (bridge_crumble_timer),a + call bridge_dissolve_run ; punch the hole in this very frame + jp bridge_paint_road_attributes ; hole cells turn to water, the rest stays road + +update_bridge_crumble: + ; A chunk goes every BRIDGE_CRUMBLE_PAUSE frames, never one column per + ; frame: the point is a rhythm of separate blasts, not a smooth dissolve. + ; Each blast takes BRIDGE_CRUMBLE_CHUNK bytes off both ends of the hole, + ; which is two 8x8 cells wide by the band's two cells tall. + ld a,(bridge_crumble_active) + or a + ret z + ld a,(bridge_crumble_timer) + dec a + ld (bridge_crumble_timer),a + ret nz + ld a,BRIDGE_CRUMBLE_PAUSE + ld (bridge_crumble_timer),a + + ; Left chunk: the run ends just left of the hole and starts CHUNK further, + ; clamped at the span's first column. + ld a,(bridge_hole_left) + ld b,a + ld a,(bridge_crumble_min) + cp b + jr z,bridge_crumble_left_done + dec b + ld a,b + ld (bridge_crumble_col_last),a + sub BRIDGE_CRUMBLE_CHUNK-1 + ld b,a + ld a,(bridge_crumble_min) + jr c,bridge_crumble_left_clamped ; borrowed past column zero: the span end wins + cp b + jr nc,bridge_crumble_left_clamped + ld a,b +bridge_crumble_left_clamped: + ld (bridge_crumble_col),a + ld (bridge_hole_left),a + call bridge_dissolve_run +bridge_crumble_left_done: + ld a,(bridge_hole_right) + ld b,a + ld a,(bridge_crumble_max) + cp b + jr z,bridge_crumble_right_done + inc b + ld a,b + ld (bridge_crumble_col),a + add a,BRIDGE_CRUMBLE_CHUNK-1 + ld b,a + ld a,(bridge_crumble_max) + cp b + jr c,bridge_crumble_right_clamped + ld a,b +bridge_crumble_right_clamped: + ld (bridge_crumble_col_last),a + ld (bridge_hole_right),a + call bridge_dissolve_run +bridge_crumble_right_done: + call bridge_crumble_blast + call bridge_paint_road_attributes + + ; The span is gone once the hole reaches both ends. Handing over to the + ; plain destroyed road from here keeps every later frame on the cheap path. + ld a,(bridge_hole_left) + ld b,a + ld a,(bridge_crumble_min) + cp b + ret nz + ld a,(bridge_hole_right) + ld b,a + ld a,(bridge_crumble_max) + cp b + ret nz + xor a + ld (bridge_crumble_active),a + ld (bridge_active),a + jp bridge_paint_destroyed_road_attributes + +bridge_crumble_blast: + ; One burst per stage, alternating ends, so the blasts read as walking out + ; from the hit instead of one continuous roar in the middle. + ld a,(bridge_crumble_phase) + xor 1 + ld (bridge_crumble_phase),a + or a + jr z,bridge_crumble_blast_right + ld a,(bridge_hole_left) + jr bridge_crumble_blast_at +bridge_crumble_blast_right: + ld a,(bridge_hole_right) +bridge_crumble_blast_at: + add a,a + add a,a + add a,a + ld c,a + ld a,(bridge_y) + add a,2 + ld b,a + ld a,c + jp start_hit_explosion + +bridge_dissolve_run: + ; Input bridge_crumble_col..bridge_crumble_col_last inclusive. Rewrite those + ; columns over the whole sixteen-line band with true world bytes, which is + ; what turns road back into river. The world query is used rather than a + ; plain zero fill because the FUEL depot belongs to that model and may share + ; these columns. +bridge_dissolve_run_column: + call bridge_dissolve_column + ld a,(bridge_crumble_col) + ld b,a + ld a,(bridge_crumble_col_last) + cp b + ret c ; a one-column run, or a malformed one + ret z + ld a,b + inc a + ld (bridge_crumble_col),a + jr bridge_dissolve_run_column + +bridge_dissolve_column: + ; Rewrite bridge_crumble_col over the band's sixteen scanlines, taken from + ; the band's live top edge so the hole scrolls with everything else. + ld a,(bridge_y) + ld (bridge_crumble_row),a + ld b,16 +bridge_dissolve_row: + ld a,(bridge_crumble_row) + cp PLAYFIELD_BOTTOM + ret nc ; the rest of the band is below the playfield + cp 16 + jr c,bridge_dissolve_next ; a band hit while still entering the screen + push bc + ld a,(bridge_crumble_col) + ld c,a + ld a,(bridge_crumble_row) + call get_world_background_byte + ld c,a + ld a,(bridge_crumble_row) + call calc_screen_line_addr + ld a,(bridge_crumble_col) + add a,l + ld l,a + ld (hl),c + pop bc +bridge_dissolve_next: + ld a,(bridge_crumble_row) + inc a + ld (bridge_crumble_row),a + djnz bridge_dissolve_row + ret + preserve_road_tank_after_bridge: ; destroy_bridge_tank_bonus has already removed a mode-1 tank if it was on ; the span. A crossing tank still on an approach stops there and becomes a @@ -2323,7 +2482,7 @@ check_player_background_pixels: ld a,6 ld (player_core_rows),a - ld a,(bridge_active) + ld a,(bridge_lethal) or a jr z,player_world_prepare ld a,(player_core_y) diff --git a/src/sprite_renderer.asm b/src/sprite_renderer.asm index 9df7be4..51dcb19 100644 --- a/src/sprite_renderer.asm +++ b/src/sprite_renderer.asm @@ -118,6 +118,8 @@ init_entities: ld (bridge_tank_side),a ld (bridge_tank_move_phase),a ld (bridge_active),a + ld (bridge_lethal),a + ld (bridge_crumble_active),a ld (destroyed_road_active),a ld (player_move),a ld (player_bank),a @@ -2291,6 +2293,9 @@ get_world_terrain_byte: sub b cp 16 jr nc,world_background_course + ld a,(background_query_col) + call bridge_column_still_road + jr z,world_background_course ; blown out of the span: plain river again ld a,255 ret #else @@ -2319,6 +2324,9 @@ world_background_band_solid: ld a,(bridge_active) or a jp z,world_background_course ; destroyed road: course model serves it + ld a,(background_query_col) + call bridge_column_still_road + jp z,world_background_course ; blown out of the span: plain river again ld a,255 ret #endif @@ -2477,6 +2485,9 @@ load_world_terrain_triplet: sub b cp 16 jr nc,world_triplet_course + ld a,(bridge_crumble_active) + or a + jr nz,world_triplet_band_crumbling ld a,255 ld (world_background_byte_0),a ld (world_background_byte_1),a @@ -2503,11 +2514,17 @@ load_world_terrain_triplet: ld a,(bridge_active) or a jr z,world_triplet_band_course_base + ld a,(bridge_crumble_active) + or a + jr nz,world_triplet_band_crumble_base ld a,255 ld (world_background_byte_0),a ld (world_background_byte_1),a ld (world_background_byte_2),a jr world_triplet_band_overlay +world_triplet_band_crumble_base: + call world_triplet_band_crumbling + jr world_triplet_band_overlay world_triplet_band_course_base: call world_triplet_course world_triplet_band_overlay: @@ -2548,6 +2565,30 @@ world_triplet_overlay_byte_1: ret #endif +world_triplet_band_crumbling: + ; Mid-crumble the band is a mixture, so the course model provides the base + ; and only the columns still standing are put back to solid road. Doing it + ; this way round keeps the hole exactly as wide as the renderer drew it. + call world_triplet_course + ld hl,world_background_byte_0 ; the three slots are consecutive + ld a,(background_query_col) + ld c,a + ld b,3 +world_triplet_crumble_byte: + push bc + push hl + ld a,c + call bridge_column_still_road + pop hl + pop bc + jr z,world_triplet_crumble_next + ld (hl),255 +world_triplet_crumble_next: + inc hl + inc c + djnz world_triplet_crumble_byte + ret + world_triplet_course: call resolve_course_block_index ld b,a @@ -3320,6 +3361,79 @@ write_world_shifted_2_addr_ready: ld (world_write_addr),hl jp write_world_shifted_2_row +bridge_paint_attr_span: + ; Input HL=the span's first colour cell; returns HL one cell past its last. + ; Colour must never run ahead of the bitmap: an 0xff road byte under water + ; paper shows its INK, which is what used to make an abandoned span glare + ; solid green. Only columns whose bitmap has genuinely become river here + ; take the water colour. + ld a,(bridge_crumble_active) + or a + jr nz,bridge_attr_span_crumbled + ld a,(bridge_width) + ld b,a + ld a,(bridge_center_attr) ; brown intact span or normal broken water + jr bridge_paint_attr_run +bridge_attr_span_crumbled: + ld a,(bridge_col) + ld c,a + ld a,(bridge_hole_left) + sub c + jr z,bridge_attr_span_hole + ld b,a + ld a,(bridge_center_attr) + call bridge_paint_attr_run +bridge_attr_span_hole: + ld a,(bridge_hole_left) + ld c,a + ld a,(bridge_hole_right) + sub c + inc a + ld b,a + ld a,0x4c ; the blown columns are ordinary water + call bridge_paint_attr_run + ld a,(bridge_col) + ld c,a + ld a,(bridge_width) + add a,c + ld c,a ; first column past the span + ld a,(bridge_hole_right) + inc a + ld b,a + ld a,c + sub b + ret z + ld b,a + ld a,(bridge_center_attr) +bridge_paint_attr_run: + ld (hl),a + inc l + djnz bridge_paint_attr_run + ret + +bridge_column_still_road: + ; Input A=byte column. Returns NZ while that column is road the world model + ; must serve as solid, and Z once the crumble has blown it away. Columns + ; outside the span are approaches and can never be in the hole. + ld b,a + ld a,(bridge_crumble_active) + or a + jr z,bridge_column_road + ld a,(bridge_hole_left) + cp b + jr z,bridge_column_gone + jr nc,bridge_column_road ; left of the hole + ld a,(bridge_hole_right) + cp b + jr c,bridge_column_road ; right of the hole +bridge_column_gone: + xor a + ret +bridge_column_road: + ld a,1 + or a + ret + bridge_fill_rows: ; Input A=start Y, B=row count, C=byte value. The bridge is maintained as ; a persistent bitmap band: only rows entering or leaving its 16-line @@ -3364,12 +3478,16 @@ bridge_fill_row: ld a,(bridge_col) add a,l ld l,a + ld a,(bridge_crumble_active) + or a + jr nz,bridge_fill_row_crumbled ld a,(bridge_width) ld b,a bridge_fill_byte: ld (hl),c inc l djnz bridge_fill_byte +bridge_fill_row_done: ld a,(bridge_rows_left) dec a ld (bridge_rows_left),a @@ -3378,6 +3496,46 @@ bridge_fill_byte: ei ret +bridge_fill_row_crumbled: + ; The hole is river the course renderer already keeps correct, so the band + ; writer steps over it rather than stamping road back into it. Only two + ; runs remain: the span up to the hole, and the span beyond it. + ld a,(bridge_col) + ld d,a + ld a,(bridge_width) + add a,d + ld d,a ; first column past the span + ld a,(bridge_col) + ld e,a + ld a,(bridge_hole_left) + sub e + jr z,bridge_fill_skip_hole + ld b,a +bridge_fill_left_byte: + ld (hl),c + inc l + djnz bridge_fill_left_byte +bridge_fill_skip_hole: + ld a,(bridge_hole_left) + ld e,a + ld a,(bridge_hole_right) + sub e + inc a + add a,l + ld l,a + ld a,(bridge_hole_right) + inc a + ld e,a + ld a,d + sub e + jr z,bridge_fill_row_done + ld b,a +bridge_fill_right_byte: + ld (hl),c + inc l + djnz bridge_fill_right_byte + jr bridge_fill_row_done + bridge_refresh_edges: ; The bank pass touches only speed_pixels residue classes modulo eight. ; A 16-line bridge therefore has two affected rows per residue: four rows @@ -3422,6 +3580,9 @@ bridge_refresh_edge_row: cp PLAYFIELD_BOTTOM ret nc call calc_screen_line_addr + ld a,(bridge_crumble_active) + or a + jr nz,bridge_refresh_edge_crumbled ld a,(bridge_col) add a,l ld l,a @@ -3437,6 +3598,38 @@ bridge_refresh_edge_row: ld (hl),255 ret +bridge_refresh_edge_crumbled: + ; The bank pass only ever reaches the span's outermost columns, and the + ; hole reaches them last, so each of the four is repaired individually + ; rather than as a pair - one end can go a stage before the other. + ld a,(bridge_col) + ld e,a + call bridge_refresh_edge_byte + inc e + call bridge_refresh_edge_byte + ld a,(bridge_col) + ld b,a + ld a,(bridge_width) + add a,b + sub 2 + ld e,a + call bridge_refresh_edge_byte + inc e + jp bridge_refresh_edge_byte + +bridge_refresh_edge_byte: + ; Input HL=row base, E=byte column; preserves both. + ld a,e + call bridge_column_still_road + ret z + push hl + ld a,e + add a,l + ld l,a + ld (hl),255 + pop hl + ret + bridge_paint_road_attributes: ; A 16-line bridge covers two attribute rows when aligned and three while ; between cells. White-on-green road attributes fill the land on both @@ -3546,13 +3739,7 @@ bridge_attr_left_byte: inc l djnz bridge_attr_left_byte bridge_attr_paint_span: - ld a,(bridge_width) - ld b,a - ld a,(bridge_center_attr) ; brown intact span or normal broken water -bridge_attr_span_byte: - ld (hl),a - inc l - djnz bridge_attr_span_byte + call bridge_paint_attr_span ; road colour, minus whatever has crumbled ld a,(bridge_col) ld b,a @@ -3761,13 +3948,7 @@ timex_bridge_attr_left_byte: inc l djnz timex_bridge_attr_left_byte timex_bridge_attr_span: - ld a,(bridge_width) - ld b,a - ld a,(bridge_center_attr) -timex_bridge_attr_center_byte: - ld (hl),a - inc l - djnz timex_bridge_attr_center_byte + call bridge_paint_attr_span ; road colour, minus whatever has crumbled ld a,(bridge_col) ld b,a ld a,(bridge_width) diff --git a/src/state.asm b/src/state.asm index abc71bf..5777d7a 100644 --- a/src/state.asm +++ b/src/state.asm @@ -119,6 +119,7 @@ tank_shell_dir: db 1 ; 1=right, 255=left tank_shell_target_x: db 120 ; safe water point selected when fired tank_splash_timer: db 0 bridge_active: db 0 ; bridge bitmap persists between frames +bridge_lethal: db 0 ; cleared by the hit: the wreck kills nobody destroyed_road_active: db 0 ; white approaches remain after span removal bridge_y: db 0 bridge_col: db 0 @@ -126,6 +127,20 @@ bridge_width: db 0 bridge_rows_left: db 0 bridge_restore_y: db 0 bridge_restore_rows: db 0 +; A hit blows a hole through the span and the hole then widens outward in +; two-byte steps, one step every BRIDGE_CRUMBLE_PAUSE frames. The span stays +; bridge_active throughout, so the wreck keeps scrolling with the world through +; the ordinary bridge machinery; only bridge_lethal and the hole columns change. +bridge_crumble_active: db 0 +bridge_crumble_timer: db 0 ; frames left before the next chunk goes +bridge_crumble_phase: db 0 ; alternates which side gets the burst +bridge_crumble_min: db 0 ; first span column +bridge_crumble_max: db 0 ; last span column +bridge_hole_left: db 0 ; lowest span column already blown away +bridge_hole_right: db 0 ; highest span column already blown away +bridge_crumble_col: db 0 ; scratch for the column being dissolved +bridge_crumble_col_last: db 0 ; scratch for the last column of the run +bridge_crumble_row: db 0 ; scratch for its current scanline bridge_attr_row: db 0 bridge_attr_rows: db 0 bridge_center_attr: db 0x4a ; BRIGHT red intact span or normal water diff --git a/tools/build.py b/tools/build.py index 92e2b66..8430923 100644 --- a/tools/build.py +++ b/tools/build.py @@ -796,6 +796,10 @@ def main() -> int: "AUTOPILOT": 0, "AUTOPILOT_NOFIRE": 0, "PLAYFIELD_BOTTOM": 168, + # Bridge crumble rhythm: frames between blasts, and the number of byte + # columns each blast takes off both ends of the hole. + "BRIDGE_CRUMBLE_PAUSE": 8, + "BRIDGE_CRUMBLE_CHUNK": 2, } for item in args.define: if "=" in item: diff --git a/tools/zrcp_bridge_screenshot.py b/tools/zrcp_bridge_screenshot.py index 456ee9a..168d94b 100644 --- a/tools/zrcp_bridge_screenshot.py +++ b/tools/zrcp_bridge_screenshot.py @@ -135,7 +135,9 @@ def capture(z, syms, prefix): z.cmd("enter-cpu-step") try: dump = z.peek(0x4000, 0x1B00) - st = z.peek(syms["bridge_active"], 5) + # Read each symbol on its own: the state block's layout is not a + # contract, and a byte inserted into it used to silently shift these. + st = [z.peek(syms[n])[0] for n in SYMBOLS[:5]] live = [n for n in ENTITY_FLAGS if z.peek(syms[n])[0]] finally: render(dump, prefix + ".png") @@ -163,8 +165,9 @@ def main(): return 0 deadline = time.time() + 600 while time.time() < deadline: - st = z.peek(syms["bridge_active"], 3) - active, destroyed, by = st[0], st[1], st[2] + active = z.peek(syms["bridge_active"])[0] + destroyed = z.peek(syms["destroyed_road_active"])[0] + by = z.peek(syms["bridge_y"])[0] want = ( (mode == "intact" and active) or (mode == "destroyed" and destroyed) diff --git a/tools/zrcp_tail_profiler.py b/tools/zrcp_tail_profiler.py index 68e71cf..e33ca4a 100644 --- a/tools/zrcp_tail_profiler.py +++ b/tools/zrcp_tail_profiler.py @@ -84,17 +84,33 @@ def sym_for(pc): buf = b"" _, buf = read_until_prompt(sock, buf) # banner + # Every command that briefly enters cpu-step mode is refused while any + # emulator menu (the tape-load progress window, say) is open. Under + # --vo null no keypress can dismiss it, so close it over the protocol and + # retry - otherwise the setup commands fail silently and the run drains the + # 1M-entry default buffer, or the drain reads a buffer that is still moving. + # Closing menus cancels a tape load, so only profile an already running game. + reply = {} + + def with_menu_retry(cmd, buf, tries=6): + for _ in range(tries): + resp, buf = command(sock, buf, cmd) + text = resp.decode().strip() + reply[cmd] = resp + print(f"{cmd.decode()}: {text or 'ok'}", flush=True) + if "Can not enter cpu step" not in text: + return buf + _, buf = command(sock, buf, b"close-all-menus") + time.sleep(1) + raise SystemExit(f"{cmd.decode()}: blocked by an emulator menu") + # PLAY_SECONDS=0 means drain-only: the emulator already holds a recorded # buffer (for example after a previous run died mid-drain in step mode). if play_seconds > 0: - # set-max-size works only while history is enabled; starting the - # recorder briefly enters cpu-step mode, which fails while any menu - # (like the tape-load progress window) is open - retry until closed. for cmd in (b"cpu-history enabled yes", b"cpu-history set-max-size %d" % MAX_SIZE, b"cpu-history get-max-size"): - resp, buf = command(sock, buf, cmd) - print(f"{cmd.decode()}: {resp.decode().strip() or 'ok'}", flush=True) + buf = with_menu_retry(cmd, buf) text = "" for _ in range(30): @@ -107,19 +123,15 @@ def sym_for(pc): else: raise SystemExit(f"could not start cpu history: {text}") - resp, buf = command(sock, buf, b"cpu-history clear") - print(f"cpu-history clear: {resp.decode().strip() or 'ok'}", flush=True) + buf = with_menu_retry(b"cpu-history clear", buf) print(f"silent for {play_seconds:.0f}s of play", flush=True) time.sleep(play_seconds) - resp, buf = command(sock, buf, b"enter-cpu-step") - text = resp.decode().strip() - if text: - print(f"enter-cpu-step: {text}", flush=True) # may already be stepped + buf = with_menu_retry(b"enter-cpu-step", buf) try: - resp, buf = command(sock, buf, b"cpu-history get-size") - size = int(resp.split()[0]) + buf = with_menu_retry(b"cpu-history get-size", buf) + size = int(reply[b"cpu-history get-size"].split()[0]) size = min(size, MAX_SIZE) print(f"draining {size} entries", flush=True) trace_new_to_old = []