From 20df6e23411e39008b3f88d75f2ab9f8f6ec4870 Mon Sep 17 00:00:00 2001 From: DNSGeek Date: Sat, 17 Oct 2026 11:18:02 -0700 Subject: [PATCH 1/7] editor: treat a cancelled or failed save as cancel in new-file and quit do_new_file and quit_dirty_prompt called do_save_file on Y and then went ahead and cleared the buffer / quit even when the save prompt was cancelled or the write failed. do_save_file clears IS_DIRTY only after a successful write, so test it afterwards and take the existing cancel path when the buffer is still dirty. Co-Authored-By: Claude Fable 5.1 --- editor.asm | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/editor.asm b/editor.asm index affb35d..ccc2864 100644 --- a/editor.asm +++ b/editor.asm @@ -679,7 +679,9 @@ do_new_file: beq @clear jmp @wait @save: - jsr do_save_file ; if save is cancelled, still proceed to clear + jsr do_save_file ; clears IS_DIRTY only on a successful write + lda IS_DIRTY + bne @cancel ; save cancelled or failed: keep the buffer @clear: ; Reset gap buffer to empty lda # Date: Sat, 17 Oct 2026 11:19:56 -0700 Subject: [PATCH 2/7] editor: prompt about unsaved changes before F3/F6 load README.md and docs/editor_manual.md promise a save prompt before a load discards unsaved changes, but the F3/F6 dispatch went straight into the filename prompt. Factor the Y/N/STOP wait that new-file and quit used into one dirty_prompt routine and guard both load keys with it ("SAVE BEFORE LOAD (Y/N)?"). A cancelled or failed save counts as cancel, as in the previous commit. Co-Authored-By: Claude Fable 5.1 --- editor.asm | 96 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 55 insertions(+), 41 deletions(-) diff --git a/editor.asm b/editor.asm index ccc2864..48a0a8b 100644 --- a/editor.asm +++ b/editor.asm @@ -555,10 +555,15 @@ main_loop: jsr do_settings_popover jmp main_loop @do_load: + jsr load_dirty_check ; unsaved changes? C=1 → cancelled + bcs @load_cancelled jsr do_load_file jmp main_loop @do_load_source: + jsr load_dirty_check + bcs @load_cancelled jsr do_load_source_file +@load_cancelled: jmp main_loop @do_save: jsr do_save_file @@ -660,28 +665,10 @@ move_begin: do_new_file: lda IS_DIRTY beq @clear - ; Prompt — reverse-video alert bar so it can't be mistaken for idle status. lda #new_dirty_text - jsr modal_draw_text -@wait: - jsr GETIN - beq @wait - cmp #PET_STOP - beq @cancel - cmp #'Y' - beq @save - cmp #'y' - beq @save - cmp #'N' - beq @clear - cmp #'n' - beq @clear - jmp @wait -@save: - jsr do_save_file ; clears IS_DIRTY only on a successful write - lda IS_DIRTY - bne @cancel ; save cancelled or failed: keep the buffer + jsr dirty_prompt ; C=1 → cancelled (STOP, or the save didn't happen) + bcs @done @clear: ; Reset gap buffer to empty lda #quit_dirty_text - jsr modal_draw_text + jmp dirty_prompt + +quit_dirty_text: + ; "SAVE BEFORE QUIT (Y/N)?" in screen codes + .byte $13,$01,$16,$05,$20,$02,$05,$06,$0F,$12,$05,$20,$11,$15,$09,$14 + .byte $20,$28,$19,$2F,$0E,$29,$3F, 0 + +; ============================================================================ +; load_dirty_check — F3/F6 guard: ask before a load discards unsaved changes. +; Returns C=0 to go ahead with the load, C=1 if the user cancelled. +; ============================================================================ + +load_dirty_check: + lda IS_DIRTY + beq @clean + lda #load_dirty_text + jmp dirty_prompt +@clean: + clc + rts + +load_dirty_text: + ; "SAVE BEFORE LOAD (Y/N)?" in screen codes + .byte $13,$01,$16,$05,$20,$02,$05,$06,$0F,$12,$05,$20,$0C,$0F,$01,$04 + .byte $20,$28,$19,$2F,$0E,$29,$3F, 0 + +; ============================================================================ +; dirty_prompt — "SAVE BEFORE xxx (Y/N)?" modal shared by new, load and quit. +; +; Entry: A/X = lo/hi of the prompt text (screen codes, modal_draw_text format). +; Y: calls do_save_file. Returns C=0 if the write succeeded; C=1 if the +; save was cancelled or failed (do_save_file leaves IS_DIRTY set then). +; N: returns C=0 — proceed, discarding the changes. +; STOP: returns C=1 — cancel; the idle status bar is restored via modal_exit. +; Clobbers: A, X, Y. +; ============================================================================ + +dirty_prompt: + jsr modal_draw_text ; reverse-video alert bar @wait: jsr GETIN beq @wait @@ -745,17 +766,15 @@ quit_dirty_prompt: cmp #'y' beq @save cmp #'N' - beq @nosave + beq @proceed cmp #'n' - beq @nosave + beq @proceed jmp @wait @save: - jsr do_save_file ; save — clears IS_DIRTY on success + jsr do_save_file ; clears IS_DIRTY only on a successful write lda IS_DIRTY - bne @cancel ; save cancelled or failed: don't quit - clc - rts -@nosave: + bne @cancel ; save cancelled or failed: keep editing +@proceed: clc rts @cancel: @@ -763,11 +782,6 @@ quit_dirty_prompt: sec rts -quit_dirty_text: - ; "SAVE BEFORE QUIT (Y/N)?" in screen codes - .byte $13,$01,$16,$05,$20,$02,$05,$06,$0F,$12,$05,$20,$11,$15,$09,$14 - .byte $20,$28,$19,$2F,$0E,$29,$3F, 0 - ; ============================================================================ ; init_settings — write compiled-in defaults into the SETTING_* BSS bytes. ; Called on both cold and warm start before load_settings overlays from disk. From 9bdd392030aeee6cef90f34bc94fe2a50742be9a Mon Sep 17 00:00:00 2001 From: DNSGeek Date: Sat, 17 Oct 2026 11:20:47 -0700 Subject: [PATCH 3/7] modules: mark the buffer dirty after a module rewrites or edits it run_module_by_index accepted status $02 (buffer replaced, e.g. Renumber) and $04 (gap repositioned, e.g. Search/Replace) without touching IS_DIRTY, so those edits never triggered the save-before-new/load/quit prompts and could be lost silently. Set IS_DIRTY on both paths. Co-Authored-By: Claude Fable 5.1 --- modules.asm | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/modules.asm b/modules.asm index 2812b88..d5fd0d1 100644 --- a/modules.asm +++ b/modules.asm @@ -837,6 +837,10 @@ run_sel_loaded: sta CURSOR_ROW sta CURSOR_COL jsr ensure_cursor_visible ; scroll/compute so display agrees with the gap + ; The module rewrote the buffer (renumber, tokenize...): the text no + ; longer matches what is on disk, so the new/load/quit prompts must fire. + lda #$FF + sta IS_DIRTY rts @check_reposition: @@ -856,6 +860,10 @@ run_sel_loaded: sta GAP_END+1 ; TOP_LINE intentionally not reset — ensure_cursor_visible (called by ; do_search_replace after we return) will scroll as needed. + ; MODSFR reports $04 after a replace as well as after a plain find, and + ; does not say which; a replace edits the buffer, so mark it dirty. + lda #$FF + sta IS_DIRTY rts @check_err: From 8575ef85941eae1c04e9eba27306b7b80697abe8 Mon Sep 17 00:00:00 2001 From: DNSGeek Date: Sat, 17 Oct 2026 11:21:29 -0700 Subject: [PATCH 4/7] modules: read MODASM's error block via MOD_LO_BASE, not literal $C00x mpop_show_error and asm_show_err read ASM_ERR / ASM_ERR_LINE_LO/HI / ASM_ERR_MSG at hard-coded $C007..$C00A. On the C128 the module state block is at MOD_LO_BASE = $9000 and $C000 is ROM, so assembler errors collapsed to the generic MODULE ERROR message. Derive the addresses from MOD_LO_BASE (layout.inc), which is $C000 on the C64 so that build is unchanged. Co-Authored-By: Claude Fable 5.1 --- modules.asm | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/modules.asm b/modules.asm index d5fd0d1..2567ec5 100644 --- a/modules.asm +++ b/modules.asm @@ -99,7 +99,7 @@ MPOP_ITEM_ROW0 = (MPOP_TOP + 3) ; screen row of first item (title at TOP+1, MOD_LOAD_SAVE_LO = $0228 MOD_LOAD_SAVE_HI = $0229 ; Index of the most recently run module — lets mpop_show_error honor -; MODASM's rich-error flag ($C007) only when MODASM actually ran. +; MODASM's rich-error flag (ASM_ERR) only when MODASM actually ran. MOD_LAST_IDX = $022A MOD_IDX_MODASM = 0 @@ -888,15 +888,15 @@ run_sel_load_err: ; ============================================================================ mpop_show_error: - ; If MODASM set ASM_ERR ($C007=$FF), show rich "ERR NNNN: MSGTEXT" display. + ; If MODASM set ASM_ERR (=$FF), show rich "ERR NNNN: MSGTEXT" display. ; Gate on "MODASM was the module that just ran": five other modules - ; LOAD at $C000, so $C007 is one of THEIR code bytes — a coincidental - ; $FF there (or a stale flag from an earlier MODASM failure) used to - ; display garbage as a rich assembler error for unrelated failures. + ; LOAD at MOD_LO_BASE, so ASM_ERR is one of THEIR code bytes — a + ; coincidental $FF there (or a stale flag from an earlier MODASM failure) + ; used to display garbage as a rich assembler error for unrelated failures. lda MOD_LAST_IDX cmp #MOD_IDX_MODASM bne @generic - lda $C007 ; ASM_ERR flag written by MODASM's set_err_common + lda ASM_ERR ; flag written by MODASM's set_err_common cmp #$FF beq asm_show_err ; MODASM error → rich display, includes wait @generic: @@ -924,8 +924,8 @@ mpop_show_error: ; ============================================================================ ; asm_show_err — rich MODASM error display. ; -; Reads ASM_ERR_LINE_LO/HI ($C008/$C009) and ASM_ERR_MSG ($C00A, screen codes, -; zero-terminated) written by MODASM's set_err_common, and formats: +; Reads ASM_ERR_LINE_LO/HI and ASM_ERR_MSG (screen codes, zero-terminated) +; written by MODASM's set_err_common, and formats: ; ; ERR NNNN: MSGTEXT ; @@ -940,9 +940,13 @@ mpop_show_error: asm_err_prefix_txt: .byte $05,$12,$12,$20 ; "ERR " -ASM_ERR_LINE_LO = $C008 -ASM_ERR_LINE_HI = $C009 -ASM_ERR_MSG = $C00A +; MODASM's error block sits at the start of its ASM_STATE block, which is +; MOD_LO_BASE (layout.inc): $C000 on the C64, $9000 on the C128. Offsets +; must match ASM_ERR / ASM_ERR_LINE_* / ASM_ERR_MSG in modasm.asm. +ASM_ERR = MOD_LO_BASE + $07 +ASM_ERR_LINE_LO = MOD_LO_BASE + $08 +ASM_ERR_LINE_HI = MOD_LO_BASE + $09 +ASM_ERR_MSG = MOD_LO_BASE + $0A asm_show_err: ; --- 1. Write "ERR " prefix --- @@ -961,9 +965,9 @@ asm_show_err: ; Load 16-bit line number into AN_CUR (reuse existing BSS scratch). ; an_emit_number writes via do_insert; we can't use it here. ; Instead: same repeated-subtraction algorithm, writing to STATUS_ROW,Y. - lda $C008 ; ASM_ERR_LINE_LO + lda ASM_ERR_LINE_LO sta AN_CUR - lda $C009 ; ASM_ERR_LINE_HI + lda ASM_ERR_LINE_HI sta AN_CUR+1 ; Reuse an_div_lo/an_div_hi table from editor.asm (same link unit). From d6df5e126053cb027017bb71b14049dbbe202da5 Mon Sep 17 00:00:00 2001 From: DNSGeek Date: Sat, 17 Oct 2026 11:22:40 -0700 Subject: [PATCH 5/7] modren: stop advance_src warping across the old gap in pass 2 setup_staging linearises the program into [BUF_END - content, BUF_END), but advance_src (used by parse_line_number and skip_to_next_cr) still jumped SRC_PTR from the editor's old GAP_START to GAP_END. When the pre-gap text is larger than the gap, the staging area straddles the old GAP_START, so pass 2 skipped gap_size bytes of program while parsing a line number and silently truncated the renumbered text. After setup_staging succeeds, point REN_GAP at REN_GAP_END so the warp is a no-op for the rest of pass 2. Co-Authored-By: Claude Fable 5.1 --- modren.asm | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/modren.asm b/modren.asm index 66e488c..a66d0ef 100644 --- a/modren.asm +++ b/modren.asm @@ -244,6 +244,17 @@ pass2: jsr setup_staging ; linearise source, set SRC_PTR bcs @error + ; The source is now linear in [staging_start..BUF_END): the editor's gap + ; no longer exists in it. advance_src (used by parse_line_number and + ; skip_to_next_cr) still warps SRC_PTR from REN_GAP to REN_GAP_END when + ; it lands on the old GAP_START — with a pre-gap larger than the gap, + ; the staging area straddles that address and the warp silently dropped + ; gap_size bytes of program mid-line. Make the warp a no-op from here on. + lda REN_GAP_END_LO + sta REN_GAP_LO + lda REN_GAP_END_HI + sta REN_GAP_HI + ; DST_PTR = work_buf lda REN_BUF_LO sta DST_PTR From d0b0281ef754925257429f21bc3a17af01fab866 Mon Sep 17 00:00:00 2001 From: DNSGeek Date: Sat, 17 Oct 2026 11:24:34 -0700 Subject: [PATCH 6/7] loadsave, modasm: CLOSE the logical file when OPEN fails The Kernal registers the logical file in its tables before the serial OPEN can fail, so a DEVICE NOT PRESENT (or any other OPEN error) left LFN 2 open for the rest of the session and every later load or save failed with FILE OPEN. Route OPEN failure through the existing CLRCHN + CLOSE path in both do_load_file's body and do_save_file. modasm's open_output had the same leak on LFN 4; it now closes the file and records I/O ERROR, which the caller already assumed had happened. Co-Authored-By: Claude Fable 5.1 --- loadsave.asm | 7 +++++-- modasm.asm | 7 ++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/loadsave.asm b/loadsave.asm index 8a9983a..5f61270 100644 --- a/loadsave.asm +++ b/loadsave.asm @@ -166,9 +166,12 @@ load_body: @name_done: ; ---- OPEN ---- + ; The Kernal registers the logical file before the serial OPEN can fail + ; (DEVICE NOT PRESENT etc.), so a failed OPEN must still CLOSE IO_LA or + ; every later load/save fails with FILE OPEN for the rest of the session. jsr OPEN bcc :+ - jmp @io_err + jmp @close_err : ; Check drive status after OPEN jsr READST @@ -459,7 +462,7 @@ do_save_file: ; ---- OPEN ---- jsr OPEN bcc :+ - jmp @io_err + jmp @close_err ; LFN is registered even when OPEN fails : jsr READST beq :+ diff --git a/modasm.asm b/modasm.asm index 06820a1..6bd9e2a 100644 --- a/modasm.asm +++ b/modasm.asm @@ -2387,7 +2387,7 @@ open_output: jsr SETNAM jsr OPEN - bcs @err + bcs @err_close ; LFN is registered even when OPEN fails ldx #ASM_OUT_LA_VAL jsr CHKOUT @@ -2407,8 +2407,13 @@ open_output: rts @err_close: + ; The Kernal registers the logical file before the serial OPEN can fail + ; (DEVICE NOT PRESENT etc.): without this CLOSE every later assembly in + ; the session fails with FILE OPEN on LFN 4. The caller expects an + ; error to have been recorded, so record I/O ERROR here. lda #ASM_OUT_LA_VAL jsr CLOSE + jsr set_err_io @err: sec rts From d5b3b913a50d420a7f980b3605cff8aabbc7bdfa Mon Sep 17 00:00:00 2001 From: DNSGeek Date: Sat, 17 Oct 2026 11:26:06 -0700 Subject: [PATCH 7/7] modasm: check the lo byte's sign in the relative-branch range test emit_instruction only tested that target-(PC+2) had a hi byte of $00 or $FF, so forward branches of +128..+255 and backward branches of -129..-256 were accepted and assembled with a wrapped offset that jumps to the wrong address. Require bit 7 of the lo byte to be clear for $00 and set for $FF, else BRANCH RANGE. tests/test_include.py gains a regression test: BEQ over 130 NOPs and BNE back over 130 NOPs must fail with BRANCH RANGE, while +120 and -128 assemble with the expected bytes. Co-Authored-By: Claude Fable 5.1 --- modasm.asm | 20 ++++++++++++++------ tests/test_include.py | 20 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/modasm.asm b/modasm.asm index 6bd9e2a..de129a7 100644 --- a/modasm.asm +++ b/modasm.asm @@ -1934,8 +1934,11 @@ emit_instruction: ; FIX (branch offset): compute signed relative offset as a proper 16-bit ; subtraction: offset = target - (PC + 2). ; PC still points to the branch opcode (not yet advanced). - ; The hi byte of the result must be $00 (forward) or $FF (backward); - ; anything else means the target is more than 128 bytes away. + ; The hi byte of the result must be $00 (forward) or $FF (backward), and + ; the lo byte's sign must agree with it: $00xx needs bit 7 of xx clear + ; (offset 0..+127), $FFxx needs it set (offset -128..-1). Checking the + ; hi byte alone accepted +128..+255 and -129..-256 and emitted an offset + ; that branched to the wrong place. ; ; Compute PC+2 into TMP2 lda ASM_PC_LO @@ -1951,13 +1954,18 @@ emit_instruction: sbc TMP2 sta TMP ; lo byte = branch offset to emit lda ASM_VAL_HI - sbc TMP2+1 ; hi byte: $00=forward in range, $FF=backward in range - beq @rel_ok + sbc TMP2+1 ; hi byte: $00=forward, $FF=backward + beq @rel_fwd cmp #$FF bne @rel_range_err + lda TMP ; backward: lo must be $80..$FF + bpl @rel_range_err ; $FF00..$FF7F = -256..-129 + bmi @rel_ok +@rel_fwd: + lda TMP ; forward: lo must be $00..$7F + bmi @rel_range_err ; $0080..$00FF = +128..+255 @rel_ok: - lda TMP - jsr emit_byte + jsr emit_byte ; A = offset lo byte bcs @err jmp @done diff --git a/tests/test_include.py b/tests/test_include.py index c9f6676..01d5988 100755 --- a/tests/test_include.py +++ b/tests/test_include.py @@ -480,6 +480,26 @@ def chain(n): equivalence("mixed directives across files", src, files) print("ok: data + code directives across multiple files") + # 14. Relative branch range. A branch whose offset is +128..+255 or + # -129..-256 has a hi byte of $00/$FF like an in-range one, so the + # check must also look at the sign of the lo byte. + def nops(n): + return " NOP\n" * n + + # forward: BEQ over 120 NOPs = +120, fine; over 130 = +130, out of range + src = " *= $C000\n BEQ FAR\n" + nops(120) + "FAR: RTS\n" + m = assemble(src) + expect_output(m, bytes.fromhex("00c0f078") + b"\xea" * 120 + b"\x60", "BEQ +120") + m = assemble(" *= $C000\n BEQ FAR\n" + nops(130) + "FAR: RTS\n") + expect_error(m, "BRANCH RANGE", "BEQ +130", line=2) + # backward: RTS; 125 NOPs; BNE BACK = -128, fine; 130 NOPs = -133, out of range + src = " *= $C000\nBACK: RTS\n" + nops(125) + " BNE BACK\n" + m = assemble(src) + expect_output(m, bytes.fromhex("00c060") + b"\xea" * 125 + b"\xd0\x80", "BNE -128") + m = assemble(" *= $C000\nBACK: RTS\n" + nops(130) + " BNE BACK\n") + expect_error(m, "BRANCH RANGE", "BNE -133", line=133) + print("ok: relative branch range (lo-byte sign checked)") + print("\nall include tests passed")