web-a2e 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. package/.clangd +5 -0
  2. package/.mcp.json +12 -0
  3. package/CLAUDE.md +362 -0
  4. package/CMakeLists.txt +774 -0
  5. package/LICENSE +21 -0
  6. package/README.md +392 -0
  7. package/build-wasm/generated/roms.cpp +2447 -0
  8. package/docker-compose.staging.yml +9 -0
  9. package/docs/basic-rom-disassembly.md +6663 -0
  10. package/docs/softswitch-comparison.md +273 -0
  11. package/docs/thunderclock-debug.md +89 -0
  12. package/examples/cube.bas +72 -0
  13. package/examples/hello.s +55 -0
  14. package/examples/scroll.s +140 -0
  15. package/package.json +18 -0
  16. package/public/assets/apple-logo-old.png +0 -0
  17. package/public/assets/apple-logo.png +0 -0
  18. package/public/assets/drive-closed-light-on.png +0 -0
  19. package/public/assets/drive-closed.png +0 -0
  20. package/public/assets/drive-open-light-on.png +0 -0
  21. package/public/assets/drive-open.png +0 -0
  22. package/public/audio-worklet.js +82 -0
  23. package/public/disks/Apple DOS 3.3 January 1983.dsk +0 -0
  24. package/public/disks/ProDOS 2.4.3.po +0 -0
  25. package/public/disks/h32mb.2mg +0 -0
  26. package/public/disks/library.json +26 -0
  27. package/public/docs/llms/llm-assembler.txt +90 -0
  28. package/public/docs/llms/llm-basic-program.txt +256 -0
  29. package/public/docs/llms/llm-disk-drives.txt +72 -0
  30. package/public/docs/llms/llm-file-explorer.txt +50 -0
  31. package/public/docs/llms/llm-hard-drives.txt +80 -0
  32. package/public/docs/llms/llm-main.txt +51 -0
  33. package/public/docs/llms/llm-slot-configuration.txt +66 -0
  34. package/public/icons/icon-192.svg +4 -0
  35. package/public/icons/icon-512.svg +4 -0
  36. package/public/index.html +661 -0
  37. package/public/llms.txt +49 -0
  38. package/public/manifest.json +29 -0
  39. package/public/shaders/burnin.glsl +22 -0
  40. package/public/shaders/crt.glsl +706 -0
  41. package/public/shaders/edge.glsl +109 -0
  42. package/public/shaders/vertex.glsl +8 -0
  43. package/public/sw.js +186 -0
  44. package/roms/341-0027.bin +0 -0
  45. package/roms/341-0160-A-US-UK.bin +0 -0
  46. package/roms/341-0160-A.bin +0 -0
  47. package/roms/342-0273-A-US-UK.bin +0 -0
  48. package/roms/342-0349-B-C0-FF.bin +0 -0
  49. package/roms/Apple Mouse Interface Card ROM - 342-0270-C.bin +0 -0
  50. package/roms/Thunderclock Plus ROM.bin +0 -0
  51. package/scripts/generate_roms.sh +69 -0
  52. package/src/bindings/wasm_interface.cpp +1940 -0
  53. package/src/core/assembler/assembler.cpp +1239 -0
  54. package/src/core/assembler/assembler.hpp +115 -0
  55. package/src/core/audio/audio.cpp +160 -0
  56. package/src/core/audio/audio.hpp +81 -0
  57. package/src/core/basic/basic_detokenizer.cpp +436 -0
  58. package/src/core/basic/basic_detokenizer.hpp +41 -0
  59. package/src/core/basic/basic_tokenizer.cpp +286 -0
  60. package/src/core/basic/basic_tokenizer.hpp +26 -0
  61. package/src/core/basic/basic_tokens.hpp +295 -0
  62. package/src/core/cards/disk2_card.cpp +568 -0
  63. package/src/core/cards/disk2_card.hpp +316 -0
  64. package/src/core/cards/expansion_card.hpp +185 -0
  65. package/src/core/cards/mockingboard/ay8910.cpp +616 -0
  66. package/src/core/cards/mockingboard/ay8910.hpp +159 -0
  67. package/src/core/cards/mockingboard/via6522.cpp +530 -0
  68. package/src/core/cards/mockingboard/via6522.hpp +163 -0
  69. package/src/core/cards/mockingboard_card.cpp +312 -0
  70. package/src/core/cards/mockingboard_card.hpp +159 -0
  71. package/src/core/cards/mouse_card.cpp +654 -0
  72. package/src/core/cards/mouse_card.hpp +190 -0
  73. package/src/core/cards/smartport/block_device.cpp +202 -0
  74. package/src/core/cards/smartport/block_device.hpp +60 -0
  75. package/src/core/cards/smartport/smartport_card.cpp +603 -0
  76. package/src/core/cards/smartport/smartport_card.hpp +120 -0
  77. package/src/core/cards/thunderclock_card.cpp +237 -0
  78. package/src/core/cards/thunderclock_card.hpp +122 -0
  79. package/src/core/cpu/cpu6502.cpp +1609 -0
  80. package/src/core/cpu/cpu6502.hpp +203 -0
  81. package/src/core/debug/condition_evaluator.cpp +470 -0
  82. package/src/core/debug/condition_evaluator.hpp +87 -0
  83. package/src/core/disassembler/disassembler.cpp +552 -0
  84. package/src/core/disassembler/disassembler.hpp +171 -0
  85. package/src/core/disk-image/disk_image.hpp +267 -0
  86. package/src/core/disk-image/dsk_disk_image.cpp +827 -0
  87. package/src/core/disk-image/dsk_disk_image.hpp +204 -0
  88. package/src/core/disk-image/gcr_encoding.cpp +147 -0
  89. package/src/core/disk-image/gcr_encoding.hpp +78 -0
  90. package/src/core/disk-image/woz_disk_image.cpp +1049 -0
  91. package/src/core/disk-image/woz_disk_image.hpp +343 -0
  92. package/src/core/emulator.cpp +2126 -0
  93. package/src/core/emulator.hpp +434 -0
  94. package/src/core/filesystem/dos33.cpp +178 -0
  95. package/src/core/filesystem/dos33.hpp +66 -0
  96. package/src/core/filesystem/pascal.cpp +262 -0
  97. package/src/core/filesystem/pascal.hpp +87 -0
  98. package/src/core/filesystem/prodos.cpp +369 -0
  99. package/src/core/filesystem/prodos.hpp +119 -0
  100. package/src/core/input/keyboard.cpp +227 -0
  101. package/src/core/input/keyboard.hpp +111 -0
  102. package/src/core/mmu/mmu.cpp +1387 -0
  103. package/src/core/mmu/mmu.hpp +236 -0
  104. package/src/core/types.hpp +196 -0
  105. package/src/core/video/video.cpp +680 -0
  106. package/src/core/video/video.hpp +156 -0
  107. package/src/css/assembler-editor.css +1617 -0
  108. package/src/css/base.css +470 -0
  109. package/src/css/basic-debugger.css +791 -0
  110. package/src/css/basic-editor.css +792 -0
  111. package/src/css/controls.css +783 -0
  112. package/src/css/cpu-debugger.css +1413 -0
  113. package/src/css/debug-base.css +160 -0
  114. package/src/css/debug-windows.css +6455 -0
  115. package/src/css/disk-drives.css +406 -0
  116. package/src/css/documentation.css +392 -0
  117. package/src/css/file-explorer.css +867 -0
  118. package/src/css/hard-drive.css +180 -0
  119. package/src/css/layout.css +217 -0
  120. package/src/css/memory-windows.css +798 -0
  121. package/src/css/modals.css +510 -0
  122. package/src/css/monitor.css +425 -0
  123. package/src/css/release-notes.css +101 -0
  124. package/src/css/responsive.css +400 -0
  125. package/src/css/rule-builder.css +340 -0
  126. package/src/css/save-states.css +201 -0
  127. package/src/css/settings-windows.css +1231 -0
  128. package/src/css/window-switcher.css +150 -0
  129. package/src/js/agent/agent-manager.js +643 -0
  130. package/src/js/agent/agent-tools.js +293 -0
  131. package/src/js/agent/agent-version-tools.js +131 -0
  132. package/src/js/agent/assembler-tools.js +357 -0
  133. package/src/js/agent/basic-program-tools.js +894 -0
  134. package/src/js/agent/disk-tools.js +417 -0
  135. package/src/js/agent/file-explorer-tools.js +269 -0
  136. package/src/js/agent/index.js +13 -0
  137. package/src/js/agent/main-tools.js +222 -0
  138. package/src/js/agent/slot-tools.js +303 -0
  139. package/src/js/agent/smartport-tools.js +257 -0
  140. package/src/js/agent/window-tools.js +80 -0
  141. package/src/js/audio/audio-driver.js +417 -0
  142. package/src/js/audio/audio-worklet.js +85 -0
  143. package/src/js/audio/index.js +8 -0
  144. package/src/js/config/default-layout.js +34 -0
  145. package/src/js/config/version.js +8 -0
  146. package/src/js/data/apple2-rom-routines.js +577 -0
  147. package/src/js/debug/assembler-editor-window.js +2993 -0
  148. package/src/js/debug/basic-breakpoint-manager.js +529 -0
  149. package/src/js/debug/basic-program-parser.js +436 -0
  150. package/src/js/debug/basic-program-window.js +2594 -0
  151. package/src/js/debug/basic-variable-inspector.js +447 -0
  152. package/src/js/debug/breakpoint-manager.js +472 -0
  153. package/src/js/debug/cpu-debugger-window.js +2396 -0
  154. package/src/js/debug/index.js +22 -0
  155. package/src/js/debug/label-manager.js +238 -0
  156. package/src/js/debug/memory-browser-window.js +416 -0
  157. package/src/js/debug/memory-heat-map-window.js +481 -0
  158. package/src/js/debug/memory-map-window.js +206 -0
  159. package/src/js/debug/mockingboard-window.js +882 -0
  160. package/src/js/debug/mouse-card-window.js +355 -0
  161. package/src/js/debug/rule-builder-window.js +648 -0
  162. package/src/js/debug/soft-switch-window.js +458 -0
  163. package/src/js/debug/stack-viewer-window.js +221 -0
  164. package/src/js/debug/symbols.js +416 -0
  165. package/src/js/debug/trace-panel.js +291 -0
  166. package/src/js/debug/zero-page-watch-window.js +297 -0
  167. package/src/js/disk-manager/disk-drives-window.js +212 -0
  168. package/src/js/disk-manager/disk-operations.js +284 -0
  169. package/src/js/disk-manager/disk-persistence.js +301 -0
  170. package/src/js/disk-manager/disk-surface-renderer.js +388 -0
  171. package/src/js/disk-manager/drive-sounds.js +139 -0
  172. package/src/js/disk-manager/hard-drive-manager.js +481 -0
  173. package/src/js/disk-manager/hard-drive-persistence.js +187 -0
  174. package/src/js/disk-manager/hard-drive-window.js +57 -0
  175. package/src/js/disk-manager/index.js +890 -0
  176. package/src/js/display/display-settings-window.js +383 -0
  177. package/src/js/display/index.js +10 -0
  178. package/src/js/display/screen-window.js +342 -0
  179. package/src/js/display/webgl-renderer.js +705 -0
  180. package/src/js/file-explorer/disassembler.js +574 -0
  181. package/src/js/file-explorer/dos33.js +266 -0
  182. package/src/js/file-explorer/file-viewer.js +359 -0
  183. package/src/js/file-explorer/index.js +1261 -0
  184. package/src/js/file-explorer/prodos.js +549 -0
  185. package/src/js/file-explorer/utils.js +67 -0
  186. package/src/js/help/documentation-window.js +1096 -0
  187. package/src/js/help/index.js +10 -0
  188. package/src/js/help/release-notes-window.js +85 -0
  189. package/src/js/help/release-notes.js +612 -0
  190. package/src/js/input/gamepad-handler.js +176 -0
  191. package/src/js/input/index.js +12 -0
  192. package/src/js/input/input-handler.js +396 -0
  193. package/src/js/input/joystick-window.js +404 -0
  194. package/src/js/input/mouse-handler.js +99 -0
  195. package/src/js/input/text-selection.js +462 -0
  196. package/src/js/main.js +653 -0
  197. package/src/js/state/index.js +15 -0
  198. package/src/js/state/save-states-window.js +393 -0
  199. package/src/js/state/state-manager.js +409 -0
  200. package/src/js/state/state-persistence.js +218 -0
  201. package/src/js/ui/confirm.js +43 -0
  202. package/src/js/ui/disk-drive-positioner.js +347 -0
  203. package/src/js/ui/reminder-controller.js +129 -0
  204. package/src/js/ui/slot-configuration-window.js +560 -0
  205. package/src/js/ui/theme-manager.js +61 -0
  206. package/src/js/ui/toast.js +44 -0
  207. package/src/js/ui/ui-controller.js +897 -0
  208. package/src/js/ui/window-switcher.js +275 -0
  209. package/src/js/utils/basic-autocomplete.js +832 -0
  210. package/src/js/utils/basic-highlighting.js +473 -0
  211. package/src/js/utils/basic-tokenizer.js +153 -0
  212. package/src/js/utils/basic-tokens.js +117 -0
  213. package/src/js/utils/constants.js +28 -0
  214. package/src/js/utils/indexeddb-helper.js +225 -0
  215. package/src/js/utils/merlin-editor-support.js +905 -0
  216. package/src/js/utils/merlin-highlighting.js +551 -0
  217. package/src/js/utils/storage.js +125 -0
  218. package/src/js/utils/string-utils.js +19 -0
  219. package/src/js/utils/wasm-memory.js +54 -0
  220. package/src/js/windows/base-window.js +690 -0
  221. package/src/js/windows/index.js +9 -0
  222. package/src/js/windows/window-manager.js +375 -0
  223. package/tests/catch2/catch.hpp +17976 -0
  224. package/tests/common/basic_program_builder.cpp +119 -0
  225. package/tests/common/basic_program_builder.hpp +209 -0
  226. package/tests/common/disk_image_builder.cpp +444 -0
  227. package/tests/common/disk_image_builder.hpp +141 -0
  228. package/tests/common/test_helpers.hpp +118 -0
  229. package/tests/gcr/gcr-test.cpp +142 -0
  230. package/tests/integration/check-rom.js +70 -0
  231. package/tests/integration/compare-boot.js +239 -0
  232. package/tests/integration/crash-trace.js +102 -0
  233. package/tests/integration/disk-boot-test.js +264 -0
  234. package/tests/integration/memory-crash.js +108 -0
  235. package/tests/integration/nibble-read-test.js +249 -0
  236. package/tests/integration/phase-test.js +159 -0
  237. package/tests/integration/test_emulator.cpp +291 -0
  238. package/tests/integration/test_emulator_basic.cpp +91 -0
  239. package/tests/integration/test_emulator_debug.cpp +344 -0
  240. package/tests/integration/test_emulator_disk.cpp +153 -0
  241. package/tests/integration/test_emulator_state.cpp +163 -0
  242. package/tests/klaus/6502_functional_test.bin +0 -0
  243. package/tests/klaus/65C02_extended_opcodes_test.bin +0 -0
  244. package/tests/klaus/klaus_6502_test.cpp +184 -0
  245. package/tests/klaus/klaus_65c02_test.cpp +197 -0
  246. package/tests/thunderclock/thunderclock_mmu_test.cpp +304 -0
  247. package/tests/thunderclock/thunderclock_test.cpp +550 -0
  248. package/tests/unit/test_assembler.cpp +521 -0
  249. package/tests/unit/test_audio.cpp +196 -0
  250. package/tests/unit/test_ay8910.cpp +311 -0
  251. package/tests/unit/test_basic_detokenizer.cpp +265 -0
  252. package/tests/unit/test_basic_tokenizer.cpp +382 -0
  253. package/tests/unit/test_block_device.cpp +259 -0
  254. package/tests/unit/test_condition_evaluator.cpp +219 -0
  255. package/tests/unit/test_cpu6502.cpp +1301 -0
  256. package/tests/unit/test_cpu_addressing.cpp +361 -0
  257. package/tests/unit/test_cpu_cycle_counts.cpp +409 -0
  258. package/tests/unit/test_cpu_decimal.cpp +166 -0
  259. package/tests/unit/test_cpu_interrupts.cpp +285 -0
  260. package/tests/unit/test_disassembler.cpp +323 -0
  261. package/tests/unit/test_disk2_card.cpp +330 -0
  262. package/tests/unit/test_dos33.cpp +273 -0
  263. package/tests/unit/test_dsk_disk_image.cpp +315 -0
  264. package/tests/unit/test_expansion_card.cpp +178 -0
  265. package/tests/unit/test_gcr_encoding.cpp +232 -0
  266. package/tests/unit/test_keyboard.cpp +262 -0
  267. package/tests/unit/test_mmu.cpp +555 -0
  268. package/tests/unit/test_mmu_slots.cpp +323 -0
  269. package/tests/unit/test_mockingboard.cpp +352 -0
  270. package/tests/unit/test_mouse_card.cpp +386 -0
  271. package/tests/unit/test_pascal.cpp +248 -0
  272. package/tests/unit/test_prodos.cpp +259 -0
  273. package/tests/unit/test_smartport_card.cpp +321 -0
  274. package/tests/unit/test_thunderclock.cpp +354 -0
  275. package/tests/unit/test_via6522.cpp +323 -0
  276. package/tests/unit/test_video.cpp +319 -0
  277. package/tests/unit/test_woz_disk_image.cpp +257 -0
  278. package/vite.config.js +96 -0
  279. package/wiki/AI-Agent.md +372 -0
  280. package/wiki/Architecture-Overview.md +303 -0
  281. package/wiki/Audio-System.md +449 -0
  282. package/wiki/CPU-Emulation.md +477 -0
  283. package/wiki/Debugger.md +516 -0
  284. package/wiki/Disk-Drives.md +161 -0
  285. package/wiki/Disk-System-Internals.md +547 -0
  286. package/wiki/Display-Settings.md +88 -0
  287. package/wiki/Expansion-Slots.md +187 -0
  288. package/wiki/File-Explorer.md +259 -0
  289. package/wiki/Getting-Started.md +156 -0
  290. package/wiki/Home.md +69 -0
  291. package/wiki/Input-Devices.md +183 -0
  292. package/wiki/Keyboard-Shortcuts.md +158 -0
  293. package/wiki/Memory-System.md +364 -0
  294. package/wiki/Save-States.md +172 -0
  295. package/wiki/Video-Rendering.md +658 -0
@@ -0,0 +1,2993 @@
1
+ /*
2
+ * assembler-editor-window.js - 65C02 assembler editor window
3
+ *
4
+ * Written by
5
+ * Mike Daley <michael_daley@icloud.com>
6
+ */
7
+
8
+ import { BaseWindow } from "../windows/base-window.js";
9
+ import { highlightMerlinSourceInline } from "../utils/merlin-highlighting.js";
10
+ import {
11
+ MerlinEditorSupport,
12
+ COL_OPCODE,
13
+ COL_OPERAND,
14
+ COL_COMMENT,
15
+ OPCODE_WIDTH,
16
+ } from "../utils/merlin-editor-support.js";
17
+ import {
18
+ ROM_ROUTINES,
19
+ ROM_CATEGORIES,
20
+ searchRoutines,
21
+ getRoutinesByCategory,
22
+ } from "../data/apple2-rom-routines.js";
23
+ import { showConfirm } from "../ui/confirm.js";
24
+
25
+ export class AssemblerEditorWindow extends BaseWindow {
26
+ constructor(wasmModule, breakpointManager, isRunningCallback, cpuDebuggerWindow) {
27
+ super({
28
+ id: "assembler-editor",
29
+ title: "Assembler",
30
+ defaultWidth: 640,
31
+ defaultHeight: 600,
32
+ minWidth: 480,
33
+ minHeight: 400,
34
+ });
35
+ this.wasmModule = wasmModule;
36
+ this.bpManager = breakpointManager;
37
+ this.isRunningCallback = isRunningCallback || (() => false);
38
+ this.cpuDebugger = cpuDebuggerWindow;
39
+ this.lastAssembledSize = 0;
40
+ this.lastOrigin = 0;
41
+ this.errors = new Map(); // line number -> error message (from assembler)
42
+ this.syntaxErrors = new Map(); // line number -> error message (from live validation)
43
+ this.currentLine = -1; // Track current line for auto-assemble
44
+ this.lineBytes = new Map(); // line number -> hex bytes string
45
+ this.linePCs = new Map(); // line number -> PC address
46
+ this.symbols = new Map(); // symbol name -> value (from last assembly)
47
+ this.currentPC = undefined; // current PC for expression evaluation
48
+ this.lineBreakpoints = new Map(); // line number -> breakpoint address
49
+ }
50
+
51
+ renderContent() {
52
+ return `
53
+ <div class="asm-editor-content">
54
+ <div class="asm-toolbar">
55
+ <div class="asm-toolbar-group asm-toolbar-actions">
56
+ <button class="asm-btn asm-assemble-btn" title="Assemble (⌘/Ctrl+Enter)">
57
+ <span class="asm-btn-icon">▶</span> Assemble
58
+ </button>
59
+ <button class="asm-btn asm-load-btn" disabled title="Write assembled code into memory">
60
+ Write
61
+ </button>
62
+ <button class="asm-btn asm-debug-btn" disabled title="Open CPU debugger at ORG address">
63
+ Debug
64
+ </button>
65
+ <button class="asm-btn asm-example-btn" title="Load example program">
66
+ <span class="asm-btn-icon">Example</span>
67
+ </button>
68
+ <button class="asm-btn asm-rom-btn" title="ROM Routines Reference (F2)">
69
+ ROM Routines
70
+ </button>
71
+ </div>
72
+ <div class="asm-toolbar-separator"></div>
73
+ <div class="asm-toolbar-group asm-toolbar-file">
74
+ <button class="asm-btn asm-new-btn" title="New (⌘/Ctrl+N)">New</button>
75
+ <button class="asm-btn asm-open-btn" title="Open File (⌘/Ctrl+O)">Open</button>
76
+ <button class="asm-btn asm-save-btn" title="Save File (⌘/Ctrl+S)">Save</button>
77
+ </div>
78
+ </div>
79
+ <div class="asm-status-bar">
80
+ <span class="asm-status"></span>
81
+ <div style="display:flex;align-items:center;gap:8px;margin-left:auto;">
82
+ <span class="asm-cursor-position">Ln 1, Col 0</span>
83
+ <span class="asm-column-indicator"></span>
84
+ </div>
85
+ </div>
86
+ <div class="asm-split-container">
87
+ <div class="asm-editor-pane">
88
+ <div class="asm-editor-wrapper">
89
+ <div class="asm-gutter-column">
90
+ <div class="asm-gutter-header">
91
+ <span class="asm-gutter-header-bp" title="Breakpoints (F9 to toggle)"></span>
92
+ <span class="asm-gutter-header-ln">#</span>
93
+ <span class="asm-gutter-header-cyc">Cyc</span>
94
+ <span class="asm-gutter-header-bytes">Bytes</span>
95
+ </div>
96
+ <div class="asm-gutter-content"></div>
97
+ </div>
98
+ <div class="asm-editor-container">
99
+ <div class="asm-editor-header">
100
+ <span class="asm-editor-header-label">Label</span>
101
+ <span class="asm-editor-header-opcode">Opcode</span>
102
+ <span class="asm-editor-header-operand">Operand</span>
103
+ <span class="asm-editor-header-comment">Comment</span>
104
+ </div>
105
+ <div class="asm-editor-scroll-area">
106
+ <div class="asm-column-guides">
107
+ <div class="asm-column-guide" data-col="${COL_OPCODE}" title="Opcode column"></div>
108
+ <div class="asm-column-guide" data-col="${COL_OPERAND}" title="Operand column"></div>
109
+ <div class="asm-column-guide" data-col="${COL_COMMENT}" title="Comment column"></div>
110
+ </div>
111
+ <div class="asm-line-highlight"></div>
112
+ <pre class="asm-highlight" aria-hidden="true"></pre>
113
+ <div class="asm-errors-overlay"></div>
114
+ <textarea class="asm-textarea" spellcheck="false"></textarea>
115
+ </div>
116
+ </div>
117
+ </div>
118
+ </div>
119
+ <div class="asm-splitter asm-splitter-h" data-direction="horizontal">
120
+ <div class="asm-splitter-handle"></div>
121
+ </div>
122
+ <div class="asm-output-pane">
123
+ <div class="asm-output-panels">
124
+ <div class="asm-panel asm-symbols-panel">
125
+ <div class="asm-panel-header">
126
+ <span class="asm-panel-title">Symbols</span>
127
+ <span class="asm-panel-count"></span>
128
+ </div>
129
+ <div class="asm-panel-content asm-symbols-content">
130
+ <div class="asm-panel-empty">
131
+ <div class="asm-empty-icon">{ }</div>
132
+ <div class="asm-empty-text">Symbols will appear here</div>
133
+ </div>
134
+ </div>
135
+ </div>
136
+ <div class="asm-splitter asm-splitter-v" data-direction="vertical">
137
+ <div class="asm-splitter-handle"></div>
138
+ </div>
139
+ <div class="asm-panel asm-hex-panel">
140
+ <div class="asm-panel-header">
141
+ <span class="asm-panel-title">Hex Output</span>
142
+ <span class="asm-panel-count"></span>
143
+ </div>
144
+ <div class="asm-panel-content asm-hex-content">
145
+ <div class="asm-panel-empty">
146
+ <div class="asm-empty-icon">[ ]</div>
147
+ <div class="asm-empty-text">Machine code will appear here</div>
148
+ </div>
149
+ </div>
150
+ </div>
151
+ </div>
152
+ </div>
153
+ </div>
154
+ <div class="asm-shortcuts-bar">
155
+ <span class="asm-shortcut"><kbd>Tab</kbd> Next column</span>
156
+ <span class="asm-shortcut"><kbd>⌘/</kbd> Comment</span>
157
+ <span class="asm-shortcut"><kbd>⌘D</kbd> Duplicate</span>
158
+ <span class="asm-shortcut"><kbd>⌘↵</kbd> Assemble</span>
159
+ <span class="asm-shortcut"><kbd>F9</kbd> Breakpoint</span>
160
+ <span class="asm-shortcut"><kbd>F2</kbd> ROM Reference</span>
161
+ </div>
162
+ <div class="asm-rom-panel hidden">
163
+ <div class="asm-rom-header">
164
+ <span class="asm-rom-title">ROM Routines</span>
165
+ <button class="asm-rom-close" title="Close (Esc)">×</button>
166
+ </div>
167
+ <div class="asm-rom-search-bar">
168
+ <input type="text" class="asm-rom-search" placeholder="Search routines..." spellcheck="false" />
169
+ <select class="asm-rom-category">
170
+ <option value="All">All Categories</option>
171
+ </select>
172
+ </div>
173
+ <div class="asm-rom-list"></div>
174
+ <div class="asm-rom-detail hidden">
175
+ <div class="asm-rom-detail-header">
176
+ <button class="asm-rom-back" title="Back to list">← Back</button>
177
+ <span class="asm-rom-detail-name"></span>
178
+ </div>
179
+ <div class="asm-rom-detail-content"></div>
180
+ <div class="asm-rom-detail-actions">
181
+ <button class="asm-btn asm-rom-insert-equ">Insert EQU</button>
182
+ <button class="asm-btn asm-rom-insert-jsr">Insert JSR</button>
183
+ </div>
184
+ </div>
185
+ </div>
186
+ </div>
187
+ `;
188
+ }
189
+
190
+ onContentRendered() {
191
+ this.textarea = this.contentElement.querySelector(".asm-textarea");
192
+ this.highlight = this.contentElement.querySelector(".asm-highlight");
193
+ this.lineHighlight = this.contentElement.querySelector(
194
+ ".asm-line-highlight",
195
+ );
196
+ this.errorsOverlay = this.contentElement.querySelector(
197
+ ".asm-errors-overlay",
198
+ );
199
+ this.gutterContent = this.contentElement.querySelector(
200
+ ".asm-gutter-content",
201
+ );
202
+ this.assembleBtn = this.contentElement.querySelector(".asm-assemble-btn");
203
+ this.loadBtn = this.contentElement.querySelector(".asm-load-btn");
204
+ this.debugBtn = this.contentElement.querySelector(".asm-debug-btn");
205
+ this.newBtn = this.contentElement.querySelector(".asm-new-btn");
206
+ this.openBtn = this.contentElement.querySelector(".asm-open-btn");
207
+ this.saveBtn = this.contentElement.querySelector(".asm-save-btn");
208
+ this.statusSpan = this.contentElement.querySelector(".asm-status");
209
+ this.currentFileName = null;
210
+ this._fileHandle = null;
211
+ this.columnIndicator = this.contentElement.querySelector(
212
+ ".asm-column-indicator",
213
+ );
214
+ this.cursorPosition = this.contentElement.querySelector(
215
+ ".asm-cursor-position",
216
+ );
217
+ this.symbolsContent = this.contentElement.querySelector(
218
+ ".asm-symbols-content",
219
+ );
220
+ this.symbolsCount = this.contentElement.querySelector(
221
+ ".asm-symbols-panel .asm-panel-count",
222
+ );
223
+ this.hexContent = this.contentElement.querySelector(".asm-hex-content");
224
+ this.hexCount = this.contentElement.querySelector(
225
+ ".asm-hex-panel .asm-panel-count",
226
+ );
227
+ this.columnGuides =
228
+ this.contentElement.querySelectorAll(".asm-column-guide");
229
+ this.editorHeader = this.contentElement.querySelector(".asm-editor-header");
230
+
231
+ // ROM panel elements
232
+ this.romBtn = this.contentElement.querySelector(".asm-rom-btn");
233
+ this.romPanel = this.contentElement.querySelector(".asm-rom-panel");
234
+ this.romSearch = this.contentElement.querySelector(".asm-rom-search");
235
+ this.romCategory = this.contentElement.querySelector(".asm-rom-category");
236
+ this.romList = this.contentElement.querySelector(".asm-rom-list");
237
+ this.romDetail = this.contentElement.querySelector(".asm-rom-detail");
238
+ this.romDetailName = this.contentElement.querySelector(
239
+ ".asm-rom-detail-name",
240
+ );
241
+ this.romDetailContent = this.contentElement.querySelector(
242
+ ".asm-rom-detail-content",
243
+ );
244
+ this.selectedRoutine = null;
245
+
246
+ // Initialize ROM panel
247
+ this.initRomPanel();
248
+
249
+ const editorContainer = this.contentElement.querySelector(
250
+ ".asm-editor-scroll-area",
251
+ );
252
+
253
+ // Position column guides and header labels based on character width
254
+ this.positionColumnGuides();
255
+
256
+ // Set placeholder with proper Merlin formatting
257
+ this.setPlaceholder();
258
+
259
+ // Sync highlighting on input
260
+ this.textarea.addEventListener("input", () => {
261
+ this.updateHighlighting();
262
+ this.updateCurrentLineHighlight();
263
+ this.updateGutter();
264
+ this.updateCursorPosition();
265
+ });
266
+
267
+ // Sync scroll position
268
+ this.textarea.addEventListener("scroll", () => {
269
+ this.highlight.scrollTop = this.textarea.scrollTop;
270
+ this.highlight.scrollLeft = this.textarea.scrollLeft;
271
+ this.gutterContent.scrollTop = this.textarea.scrollTop;
272
+ this.errorsOverlay.style.top = `-${this.textarea.scrollTop}px`;
273
+ this.updateCurrentLineHighlight();
274
+ });
275
+
276
+ // Track cursor for line highlight and auto-format on line change
277
+ this.textarea.addEventListener("click", () => {
278
+ this.updateCurrentLineHighlight();
279
+ this.checkLineChangeAndFormat();
280
+ this.updateCursorPosition();
281
+ });
282
+ this.textarea.addEventListener("keyup", (e) => {
283
+ // Check for navigation keys that might change lines
284
+ if (
285
+ [
286
+ "ArrowUp",
287
+ "ArrowDown",
288
+ "Enter",
289
+ "PageUp",
290
+ "PageDown",
291
+ "Home",
292
+ "End",
293
+ ].includes(e.key)
294
+ ) {
295
+ this.checkLineChangeAndFormat();
296
+ this.scrollCursorIntoView();
297
+ }
298
+ this.updateCursorPosition();
299
+ });
300
+ this.textarea.addEventListener("keydown", (e) => {
301
+ requestAnimationFrame(() => {
302
+ this.updateCurrentLineHighlight();
303
+ this.updateCursorPosition();
304
+ // Scroll for navigation keys
305
+ if (
306
+ [
307
+ "ArrowUp",
308
+ "ArrowDown",
309
+ "Enter",
310
+ "PageUp",
311
+ "PageDown",
312
+ "Home",
313
+ "End",
314
+ ].includes(e.key)
315
+ ) {
316
+ this.scrollCursorIntoView();
317
+ }
318
+ });
319
+ });
320
+ this.textarea.addEventListener("focus", () => {
321
+ this.lineHighlight.classList.add("visible");
322
+ this.updateCurrentLineHighlight();
323
+ this.currentLine = this.getCurrentLineNumber();
324
+ this.updateCursorPosition();
325
+ });
326
+ this.textarea.addEventListener("blur", () => {
327
+ this.lineHighlight.classList.remove("visible");
328
+ });
329
+
330
+ // Assemble button
331
+ this.assembleBtn.addEventListener("click", () => this.doAssemble());
332
+
333
+ // Load button
334
+ this.loadBtn.addEventListener("click", () => this.doLoad());
335
+
336
+ // Debug button
337
+ this.debugBtn.addEventListener("click", () => {
338
+ if (this.cpuDebugger) {
339
+ this.cpuDebugger.show();
340
+ this.cpuDebugger.disasmViewAddress = this.lastOrigin;
341
+ this.cpuDebugger.updateDisassembly();
342
+ }
343
+ });
344
+
345
+ // Clear button
346
+
347
+ // Example button
348
+ this.contentElement
349
+ .querySelector(".asm-example-btn")
350
+ .addEventListener("click", () => this.loadExample());
351
+
352
+ // File management buttons
353
+ this.newBtn.addEventListener("click", () => this.newFile());
354
+ this.openBtn.addEventListener("click", () => this.openFile());
355
+ this.saveBtn.addEventListener("click", () => this.saveFile());
356
+
357
+ // Editor support (Tab nav, smart enter, autocomplete, etc.)
358
+ this.editorSupport = new MerlinEditorSupport(
359
+ this.textarea,
360
+ editorContainer,
361
+ {
362
+ onColumnChange: (name, col) => this.updateColumnIndicator(name, col),
363
+ onAssemble: () => this.doAssemble(),
364
+ },
365
+ );
366
+
367
+ // Splitters
368
+ this.initSplitters();
369
+
370
+ // Reposition column guides on window resize
371
+ const resizeObserver = new ResizeObserver(() => {
372
+ this.positionColumnGuides();
373
+ });
374
+ resizeObserver.observe(editorContainer);
375
+
376
+ // Gutter click handler for breakpoints
377
+ this.gutterContent.addEventListener("click", (e) => {
378
+ const gutterLine = e.target.closest(".asm-gutter-line");
379
+ if (gutterLine) {
380
+ const lineNumber = parseInt(gutterLine.dataset.line, 10);
381
+ if (lineNumber) {
382
+ this.toggleBreakpoint(lineNumber);
383
+ }
384
+ }
385
+ });
386
+
387
+ // Keyboard shortcuts
388
+ this.textarea.addEventListener("keydown", (e) => {
389
+ const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
390
+ const modKey = isMac ? e.metaKey : e.ctrlKey;
391
+
392
+ if (e.key === "F9") {
393
+ e.preventDefault();
394
+ const lineNumber = this.getCurrentLineNumber();
395
+ this.toggleBreakpoint(lineNumber);
396
+ } else if (e.key === "F2") {
397
+ e.preventDefault();
398
+ this.toggleRomPanel();
399
+ } else if (modKey && e.key.toLowerCase() === "n") {
400
+ e.preventDefault();
401
+ this.newFile();
402
+ } else if (modKey && e.key.toLowerCase() === "o") {
403
+ e.preventDefault();
404
+ this.openFile();
405
+ } else if (modKey && e.key.toLowerCase() === "s") {
406
+ e.preventDefault();
407
+ this.saveFile();
408
+ }
409
+ });
410
+
411
+ // Listen for breakpoint changes from the manager
412
+ if (this.bpManager) {
413
+ this.bpManager.onChange(() => this.syncBreakpointsFromManager());
414
+ }
415
+
416
+ this.updateHighlighting();
417
+ this.validateAllLines();
418
+ this.encodeAllLineBytes();
419
+ this.updateGutter();
420
+ this.updateCursorPosition();
421
+ }
422
+
423
+ setPlaceholder() {
424
+ // Set a well-formatted Hello World example as placeholder
425
+ const example = `**********************************
426
+ * *
427
+ * HELLO WORLD FOR 6502 *
428
+ * APPLE ][, MERLIN ASSEMBLER *
429
+ * *
430
+ **********************************
431
+
432
+ STROUT EQU $DB3A ;Outputs AY-pointed null-terminated string
433
+
434
+ ORG $0800 ;Standard BASIC program area
435
+
436
+ START LDY #>HELLO
437
+ LDA #<HELLO
438
+ JMP STROUT
439
+
440
+ HELLO ASC "HELLO WORLD!!!!!!",00`;
441
+ this.textarea.placeholder = example;
442
+ }
443
+
444
+ positionColumnGuides() {
445
+ // Calculate character width based on font metrics
446
+ const style = getComputedStyle(this.textarea);
447
+ const fontSize = parseFloat(style.fontSize) || 12;
448
+ const charWidth = fontSize * 0.6; // Approximate monospace character width
449
+ const paddingLeft = parseFloat(style.paddingLeft) || 8;
450
+
451
+ // Position column guide lines
452
+ this.columnGuides.forEach((guide) => {
453
+ const col = parseInt(guide.dataset.col, 10);
454
+ guide.style.left = `${paddingLeft + col * charWidth}px`;
455
+ });
456
+
457
+ // Position header labels at Merlin column positions
458
+ if (this.editorHeader) {
459
+ const labelEl = this.editorHeader.querySelector(
460
+ ".asm-editor-header-label",
461
+ );
462
+ const opcodeEl = this.editorHeader.querySelector(
463
+ ".asm-editor-header-opcode",
464
+ );
465
+ const operandEl = this.editorHeader.querySelector(
466
+ ".asm-editor-header-operand",
467
+ );
468
+ const commentEl = this.editorHeader.querySelector(
469
+ ".asm-editor-header-comment",
470
+ );
471
+
472
+ if (labelEl) labelEl.style.left = `${paddingLeft}px`;
473
+ if (opcodeEl)
474
+ opcodeEl.style.left = `${paddingLeft + COL_OPCODE * charWidth}px`;
475
+ if (operandEl)
476
+ operandEl.style.left = `${paddingLeft + COL_OPERAND * charWidth}px`;
477
+ if (commentEl)
478
+ commentEl.style.left = `${paddingLeft + COL_COMMENT * charWidth}px`;
479
+ }
480
+ }
481
+
482
+ initSplitters() {
483
+ const splitters = this.contentElement.querySelectorAll(".asm-splitter");
484
+ for (const splitter of splitters) {
485
+ splitter.addEventListener("mousedown", (e) =>
486
+ this.onSplitterMouseDown(e, splitter),
487
+ );
488
+ }
489
+ }
490
+
491
+ onSplitterMouseDown(e, splitter) {
492
+ e.preventDefault();
493
+ const direction = splitter.dataset.direction;
494
+ const isHorizontal = direction === "horizontal";
495
+
496
+ const container = splitter.parentElement;
497
+ const prevEl = splitter.previousElementSibling;
498
+ const nextEl = splitter.nextElementSibling;
499
+
500
+ const containerRect = container.getBoundingClientRect();
501
+ const startPos = isHorizontal ? e.clientY : e.clientX;
502
+ const prevSize = isHorizontal
503
+ ? prevEl.getBoundingClientRect().height
504
+ : prevEl.getBoundingClientRect().width;
505
+
506
+ const totalSize = isHorizontal
507
+ ? containerRect.height - splitter.offsetHeight
508
+ : containerRect.width - splitter.offsetWidth;
509
+
510
+ const minSize = 60;
511
+
512
+ const onMouseMove = (e) => {
513
+ const currentPos = isHorizontal ? e.clientY : e.clientX;
514
+ const delta = currentPos - startPos;
515
+ let newPrevSize = prevSize + delta;
516
+
517
+ // Clamp
518
+ newPrevSize = Math.max(
519
+ minSize,
520
+ Math.min(totalSize - minSize, newPrevSize),
521
+ );
522
+
523
+ const prevPercent = (newPrevSize / totalSize) * 100;
524
+ const nextPercent = 100 - prevPercent;
525
+
526
+ prevEl.style.flex = `0 0 ${prevPercent}%`;
527
+ nextEl.style.flex = `0 0 ${nextPercent}%`;
528
+ };
529
+
530
+ const onMouseUp = () => {
531
+ document.removeEventListener("mousemove", onMouseMove);
532
+ document.removeEventListener("mouseup", onMouseUp);
533
+ document.body.style.cursor = "";
534
+ document.body.style.userSelect = "";
535
+ };
536
+
537
+ document.addEventListener("mousemove", onMouseMove);
538
+ document.addEventListener("mouseup", onMouseUp);
539
+ document.body.style.cursor = isHorizontal ? "row-resize" : "col-resize";
540
+ document.body.style.userSelect = "none";
541
+ }
542
+
543
+ updateColumnIndicator(name, col) {
544
+ if (!this.columnIndicator) return;
545
+ const displayNames = {
546
+ label: "Label",
547
+ opcode: "Opcode",
548
+ operand: "Operand",
549
+ comment: "Comment",
550
+ };
551
+ const display = displayNames[name] || name;
552
+ this.columnIndicator.textContent = display;
553
+ this.columnIndicator.className = `asm-column-indicator asm-col-${name}`;
554
+ }
555
+
556
+ updateCursorPosition() {
557
+ if (!this.cursorPosition || !this.textarea) return;
558
+ const text = this.textarea.value.substring(0, this.textarea.selectionStart);
559
+ const lines = text.split("\n");
560
+ const lineNum = lines.length;
561
+ const col = lines[lines.length - 1].length;
562
+ this.cursorPosition.textContent = `Ln ${lineNum}, Col ${col}`;
563
+ }
564
+
565
+ /**
566
+ * Scroll the textarea to keep the cursor line visible
567
+ */
568
+ scrollCursorIntoView() {
569
+ if (!this.textarea) return;
570
+
571
+ const text = this.textarea.value.substring(0, this.textarea.selectionStart);
572
+ const lineIndex = text.split("\n").length - 1;
573
+
574
+ const style = getComputedStyle(this.textarea);
575
+ const lineHeight =
576
+ parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.4;
577
+ const paddingTop = parseFloat(style.paddingTop) || 8;
578
+ const paddingBottom = parseFloat(style.paddingBottom) || 8;
579
+
580
+ const cursorTop = paddingTop + lineIndex * lineHeight;
581
+ const cursorBottom = cursorTop + lineHeight;
582
+
583
+ const viewportTop = this.textarea.scrollTop;
584
+ const viewportBottom =
585
+ viewportTop + this.textarea.clientHeight - paddingBottom;
586
+
587
+ // Scroll up if cursor is above viewport
588
+ if (cursorTop < viewportTop + paddingTop) {
589
+ this.textarea.scrollTop = cursorTop - paddingTop;
590
+ }
591
+ // Scroll down if cursor is below viewport
592
+ else if (cursorBottom > viewportBottom) {
593
+ this.textarea.scrollTop =
594
+ cursorBottom - this.textarea.clientHeight + paddingBottom + paddingTop;
595
+ }
596
+ }
597
+
598
+ updateCurrentLineHighlight() {
599
+ if (!this.lineHighlight || !this.textarea) return;
600
+ const text = this.textarea.value.substring(0, this.textarea.selectionStart);
601
+ const lineIndex = text.split("\n").length - 1;
602
+ const style = getComputedStyle(this.textarea);
603
+ const lineHeight =
604
+ parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.4;
605
+ const paddingTop = parseFloat(style.paddingTop) || 0;
606
+ const top = paddingTop + lineIndex * lineHeight - this.textarea.scrollTop;
607
+ this.lineHighlight.style.top = `${top}px`;
608
+ this.lineHighlight.style.height = `${lineHeight}px`;
609
+ }
610
+
611
+ getCurrentLineNumber() {
612
+ if (!this.textarea) return 0;
613
+ const text = this.textarea.value.substring(0, this.textarea.selectionStart);
614
+ return text.split("\n").length;
615
+ }
616
+
617
+ checkLineChangeAndFormat() {
618
+ const newLine = this.getCurrentLineNumber();
619
+ if (newLine !== this.currentLine && this.currentLine !== -1) {
620
+ // Format, validate, and encode the line we're leaving
621
+ this.formatLine(this.currentLine);
622
+ this.validateLine(this.currentLine);
623
+ this.encodeLineBytes(this.currentLine);
624
+ this.updateGutter();
625
+ this.updateErrorsOverlay();
626
+ this.currentLine = newLine;
627
+ } else {
628
+ this.currentLine = newLine;
629
+ }
630
+ }
631
+
632
+ formatLine(lineNumber) {
633
+ if (!this.textarea) return;
634
+
635
+ const lines = this.textarea.value.split("\n");
636
+ if (lineNumber < 1 || lineNumber > lines.length) return;
637
+
638
+ const lineIndex = lineNumber - 1;
639
+ const line = lines[lineIndex];
640
+
641
+ // Skip empty lines or comment-only lines
642
+ if (
643
+ !line.trim() ||
644
+ line.trim().startsWith(";") ||
645
+ line.trim().startsWith("*")
646
+ ) {
647
+ return;
648
+ }
649
+
650
+ // Parse the line into components
651
+ const parsed = this.parseLine(line);
652
+ if (!parsed) return;
653
+
654
+ // Rebuild with proper column alignment
655
+ const formatted = this.buildFormattedLine(parsed);
656
+
657
+ // Only update if different
658
+ if (formatted !== line) {
659
+ lines[lineIndex] = formatted;
660
+ const cursorPos = this.textarea.selectionStart;
661
+ this.textarea.value = lines.join("\n");
662
+ this.textarea.selectionStart = this.textarea.selectionEnd = cursorPos;
663
+ this.updateHighlighting();
664
+ }
665
+ }
666
+
667
+ formatAllLines() {
668
+ if (!this.textarea) return;
669
+
670
+ const lines = this.textarea.value.split("\n");
671
+ let changed = false;
672
+
673
+ for (let i = 0; i < lines.length; i++) {
674
+ const line = lines[i];
675
+
676
+ // Skip empty lines or comment-only lines
677
+ if (
678
+ !line.trim() ||
679
+ line.trim().startsWith(";") ||
680
+ line.trim().startsWith("*")
681
+ ) {
682
+ continue;
683
+ }
684
+
685
+ // Parse the line into components
686
+ const parsed = this.parseLine(line);
687
+ if (!parsed) continue;
688
+
689
+ // Rebuild with proper column alignment
690
+ const formatted = this.buildFormattedLine(parsed);
691
+
692
+ if (formatted !== line) {
693
+ lines[i] = formatted;
694
+ changed = true;
695
+ }
696
+ }
697
+
698
+ if (changed) {
699
+ const cursorPos = this.textarea.selectionStart;
700
+ this.textarea.value = lines.join("\n");
701
+ this.textarea.selectionStart = this.textarea.selectionEnd = cursorPos;
702
+ this.updateHighlighting();
703
+ }
704
+ }
705
+
706
+ parseLine(line) {
707
+ // Merlin column layout: Label, Opcode, Operand, Comment (see COL_* constants)
708
+ let label = "";
709
+ let opcode = "";
710
+ let operand = "";
711
+ let comment = "";
712
+
713
+ // Check for comment at start of line (full-line comment)
714
+ if (line.trim().startsWith(";") || line.trim().startsWith("*")) {
715
+ return null;
716
+ }
717
+
718
+ // Find comment (starts with ;)
719
+ let commentIdx = -1;
720
+ let inQuote = false;
721
+ for (let i = 0; i < line.length; i++) {
722
+ const ch = line[i];
723
+ if (ch === '"' || ch === "'") inQuote = !inQuote;
724
+ if (ch === ";" && !inQuote) {
725
+ commentIdx = i;
726
+ break;
727
+ }
728
+ }
729
+
730
+ let mainPart = commentIdx >= 0 ? line.substring(0, commentIdx) : line;
731
+ comment = commentIdx >= 0 ? line.substring(commentIdx) : "";
732
+
733
+ // Check if line starts with whitespace (no label)
734
+ const startsWithSpace =
735
+ mainPart.length > 0 && (mainPart[0] === " " || mainPart[0] === "\t");
736
+
737
+ // Split main part by whitespace
738
+ const tokens = mainPart.trim().split(/\s+/);
739
+
740
+ if (tokens.length === 0 || (tokens.length === 1 && tokens[0] === "")) {
741
+ return null;
742
+ }
743
+
744
+ if (startsWithSpace) {
745
+ // No label - first token is opcode
746
+ opcode = tokens[0] || "";
747
+ operand = tokens.slice(1).join(" ");
748
+ } else {
749
+ // First token is label
750
+ label = tokens[0] || "";
751
+ opcode = tokens[1] || "";
752
+ operand = tokens.slice(2).join(" ");
753
+ }
754
+
755
+ return { label, opcode, operand, comment };
756
+ }
757
+
758
+ buildFormattedLine(parsed) {
759
+ const { label, opcode, operand, comment } = parsed;
760
+
761
+ let result = "";
762
+
763
+ // Label column
764
+ result = label.padEnd(COL_OPCODE, " ");
765
+
766
+ // Opcode column
767
+ if (opcode) {
768
+ result =
769
+ result.substring(0, COL_OPCODE) +
770
+ opcode.toUpperCase().padEnd(OPCODE_WIDTH, " ");
771
+ }
772
+
773
+ // Operand column
774
+ if (operand) {
775
+ result = result.substring(0, COL_OPERAND) + operand;
776
+ }
777
+
778
+ // Comment column
779
+ if (comment) {
780
+ const currentLen = result.trimEnd().length;
781
+ if (currentLen < COL_COMMENT) {
782
+ result = result.trimEnd().padEnd(COL_COMMENT, " ") + comment;
783
+ } else {
784
+ result = result.trimEnd() + " " + comment;
785
+ }
786
+ }
787
+
788
+ return result.trimEnd();
789
+ }
790
+
791
+ // 65C02 instruction info: { cycles, bytes } by mnemonic
792
+ // Cycles shown are base cycles (some modes add +1 for page crossing)
793
+ getInstructionInfo() {
794
+ return {
795
+ // Branch / Flow
796
+ JMP: { cycles: 3, bytes: 3 },
797
+ JSR: { cycles: 6, bytes: 3 },
798
+ BCC: { cycles: 2, bytes: 2 },
799
+ BCS: { cycles: 2, bytes: 2 },
800
+ BEQ: { cycles: 2, bytes: 2 },
801
+ BMI: { cycles: 2, bytes: 2 },
802
+ BNE: { cycles: 2, bytes: 2 },
803
+ BPL: { cycles: 2, bytes: 2 },
804
+ BRA: { cycles: 3, bytes: 2 },
805
+ BVC: { cycles: 2, bytes: 2 },
806
+ BVS: { cycles: 2, bytes: 2 },
807
+ RTS: { cycles: 6, bytes: 1 },
808
+ RTI: { cycles: 6, bytes: 1 },
809
+ BRK: { cycles: 7, bytes: 1 },
810
+ // Load / Store
811
+ LDA: { cycles: 2, bytes: 2 },
812
+ LDX: { cycles: 2, bytes: 2 },
813
+ LDY: { cycles: 2, bytes: 2 },
814
+ STA: { cycles: 3, bytes: 2 },
815
+ STX: { cycles: 3, bytes: 2 },
816
+ STY: { cycles: 3, bytes: 2 },
817
+ STZ: { cycles: 3, bytes: 2 },
818
+ // Math / Logic
819
+ ADC: { cycles: 2, bytes: 2 },
820
+ SBC: { cycles: 2, bytes: 2 },
821
+ AND: { cycles: 2, bytes: 2 },
822
+ ORA: { cycles: 2, bytes: 2 },
823
+ EOR: { cycles: 2, bytes: 2 },
824
+ ASL: { cycles: 2, bytes: 1 },
825
+ LSR: { cycles: 2, bytes: 1 },
826
+ ROL: { cycles: 2, bytes: 1 },
827
+ ROR: { cycles: 2, bytes: 1 },
828
+ INC: { cycles: 2, bytes: 1 },
829
+ DEC: { cycles: 2, bytes: 1 },
830
+ INA: { cycles: 2, bytes: 1 },
831
+ DEA: { cycles: 2, bytes: 1 },
832
+ INX: { cycles: 2, bytes: 1 },
833
+ DEX: { cycles: 2, bytes: 1 },
834
+ INY: { cycles: 2, bytes: 1 },
835
+ DEY: { cycles: 2, bytes: 1 },
836
+ CMP: { cycles: 2, bytes: 2 },
837
+ CPX: { cycles: 2, bytes: 2 },
838
+ CPY: { cycles: 2, bytes: 2 },
839
+ BIT: { cycles: 2, bytes: 2 },
840
+ TRB: { cycles: 5, bytes: 2 },
841
+ TSB: { cycles: 5, bytes: 2 },
842
+ // Stack / Transfer
843
+ PHA: { cycles: 3, bytes: 1 },
844
+ PHP: { cycles: 3, bytes: 1 },
845
+ PHX: { cycles: 3, bytes: 1 },
846
+ PHY: { cycles: 3, bytes: 1 },
847
+ PLA: { cycles: 4, bytes: 1 },
848
+ PLP: { cycles: 4, bytes: 1 },
849
+ PLX: { cycles: 4, bytes: 1 },
850
+ PLY: { cycles: 4, bytes: 1 },
851
+ TAX: { cycles: 2, bytes: 1 },
852
+ TAY: { cycles: 2, bytes: 1 },
853
+ TSX: { cycles: 2, bytes: 1 },
854
+ TXA: { cycles: 2, bytes: 1 },
855
+ TXS: { cycles: 2, bytes: 1 },
856
+ TYA: { cycles: 2, bytes: 1 },
857
+ // Flags
858
+ CLC: { cycles: 2, bytes: 1 },
859
+ CLD: { cycles: 2, bytes: 1 },
860
+ CLI: { cycles: 2, bytes: 1 },
861
+ CLV: { cycles: 2, bytes: 1 },
862
+ SEC: { cycles: 2, bytes: 1 },
863
+ SED: { cycles: 2, bytes: 1 },
864
+ SEI: { cycles: 2, bytes: 1 },
865
+ NOP: { cycles: 2, bytes: 1 },
866
+ WAI: { cycles: 3, bytes: 1 },
867
+ STP: { cycles: 3, bytes: 1 },
868
+ // 65C02 BBR/BBS (3 bytes: opcode, zp address, relative offset)
869
+ BBR0: { cycles: 5, bytes: 3 },
870
+ BBR1: { cycles: 5, bytes: 3 },
871
+ BBR2: { cycles: 5, bytes: 3 },
872
+ BBR3: { cycles: 5, bytes: 3 },
873
+ BBR4: { cycles: 5, bytes: 3 },
874
+ BBR5: { cycles: 5, bytes: 3 },
875
+ BBR6: { cycles: 5, bytes: 3 },
876
+ BBR7: { cycles: 5, bytes: 3 },
877
+ BBS0: { cycles: 5, bytes: 3 },
878
+ BBS1: { cycles: 5, bytes: 3 },
879
+ BBS2: { cycles: 5, bytes: 3 },
880
+ BBS3: { cycles: 5, bytes: 3 },
881
+ BBS4: { cycles: 5, bytes: 3 },
882
+ BBS5: { cycles: 5, bytes: 3 },
883
+ BBS6: { cycles: 5, bytes: 3 },
884
+ BBS7: { cycles: 5, bytes: 3 },
885
+ // 65C02 RMB/SMB (2 bytes: opcode, zp address)
886
+ RMB0: { cycles: 5, bytes: 2 },
887
+ RMB1: { cycles: 5, bytes: 2 },
888
+ RMB2: { cycles: 5, bytes: 2 },
889
+ RMB3: { cycles: 5, bytes: 2 },
890
+ RMB4: { cycles: 5, bytes: 2 },
891
+ RMB5: { cycles: 5, bytes: 2 },
892
+ RMB6: { cycles: 5, bytes: 2 },
893
+ RMB7: { cycles: 5, bytes: 2 },
894
+ SMB0: { cycles: 5, bytes: 2 },
895
+ SMB1: { cycles: 5, bytes: 2 },
896
+ SMB2: { cycles: 5, bytes: 2 },
897
+ SMB3: { cycles: 5, bytes: 2 },
898
+ SMB4: { cycles: 5, bytes: 2 },
899
+ SMB5: { cycles: 5, bytes: 2 },
900
+ SMB6: { cycles: 5, bytes: 2 },
901
+ SMB7: { cycles: 5, bytes: 2 },
902
+ };
903
+ }
904
+
905
+ updateGutter() {
906
+ if (!this.gutterContent || !this.textarea) return;
907
+
908
+ const lines = this.textarea.value.split("\n");
909
+ const instrInfo = this.getInstructionInfo();
910
+ const opcodeTable = this.getOpcodeTable();
911
+ const gutterLines = [];
912
+ const numWidth = Math.max(2, String(lines.length).length);
913
+
914
+ for (let i = 0; i < lines.length; i++) {
915
+ const lineNum = String(i + 1).padStart(numWidth, " ");
916
+ const lineNumber = i + 1;
917
+ const parsed = this.parseLine(lines[i]);
918
+ let cycles = "";
919
+ let bytesHex = this.lineBytes.get(lineNumber) || "";
920
+ const hasError =
921
+ this.errors.has(lineNumber) || this.syntaxErrors.has(lineNumber);
922
+ const errorClass = hasError ? " asm-gutter-error" : "";
923
+
924
+ // Check if this line has an actual instruction (not a directive, comment, or label-only)
925
+ const isInstruction =
926
+ parsed && parsed.opcode && opcodeTable[parsed.opcode.toUpperCase()];
927
+
928
+ // Check for breakpoint at this line's address (only for instruction lines)
929
+ const lineAddr = this.linePCs.get(lineNumber);
930
+ const hasBreakpoint =
931
+ isInstruction &&
932
+ lineAddr !== undefined &&
933
+ this.bpManager?.has(lineAddr);
934
+ const bpClass = hasBreakpoint ? " asm-gutter-bp" : "";
935
+
936
+ // Breakpoint indicator: red dot for breakpoint, clickable space for instruction lines, nothing for non-instructions
937
+ let bpIndicator;
938
+ if (hasBreakpoint) {
939
+ bpIndicator = '<span class="asm-gutter-bp-dot"></span>';
940
+ } else if (isInstruction) {
941
+ bpIndicator =
942
+ '<span class="asm-gutter-bp-space asm-gutter-bp-clickable"></span>';
943
+ } else {
944
+ bpIndicator = '<span class="asm-gutter-bp-space"></span>';
945
+ }
946
+
947
+ if (parsed && parsed.opcode) {
948
+ const mnem = parsed.opcode.toUpperCase();
949
+ const info = instrInfo[mnem];
950
+ if (info) {
951
+ cycles = String(info.cycles);
952
+ }
953
+ }
954
+
955
+ gutterLines.push(
956
+ `<div class="asm-gutter-line${errorClass}${bpClass}" data-line="${lineNumber}">` +
957
+ `${bpIndicator}` +
958
+ `<span class="asm-gutter-ln">${lineNum}</span>` +
959
+ `<span class="asm-gutter-cyc">${cycles || ""}</span>` +
960
+ `<span class="asm-gutter-bytes">${bytesHex || ""}</span>` +
961
+ `</div>`,
962
+ );
963
+ }
964
+
965
+ this.gutterContent.innerHTML = gutterLines.join("");
966
+ }
967
+
968
+ // Compatibility alias
969
+ updateCyclesGutter() {
970
+ this.updateGutter();
971
+ }
972
+
973
+ // 65C02 opcode encoding table: mnemonic -> { mode: opcode }
974
+ // Modes: IMP, ACC, IMM, ZP, ZPX, ZPY, ABS, ABX, ABY, IND, IZX, IZY, ZPI, REL
975
+ getOpcodeTable() {
976
+ return {
977
+ ADC: {
978
+ IMM: 0x69,
979
+ ZP: 0x65,
980
+ ZPX: 0x75,
981
+ ABS: 0x6d,
982
+ ABX: 0x7d,
983
+ ABY: 0x79,
984
+ IZX: 0x61,
985
+ IZY: 0x71,
986
+ ZPI: 0x72,
987
+ },
988
+ AND: {
989
+ IMM: 0x29,
990
+ ZP: 0x25,
991
+ ZPX: 0x35,
992
+ ABS: 0x2d,
993
+ ABX: 0x3d,
994
+ ABY: 0x39,
995
+ IZX: 0x21,
996
+ IZY: 0x31,
997
+ ZPI: 0x32,
998
+ },
999
+ ASL: { IMP: 0x0a, ACC: 0x0a, ZP: 0x06, ZPX: 0x16, ABS: 0x0e, ABX: 0x1e },
1000
+ BCC: { REL: 0x90 },
1001
+ BCS: { REL: 0xb0 },
1002
+ BEQ: { REL: 0xf0 },
1003
+ BIT: { IMM: 0x89, ZP: 0x24, ZPX: 0x34, ABS: 0x2c, ABX: 0x3c },
1004
+ BMI: { REL: 0x30 },
1005
+ BNE: { REL: 0xd0 },
1006
+ BPL: { REL: 0x10 },
1007
+ BRA: { REL: 0x80 },
1008
+ BRK: { IMP: 0x00 },
1009
+ BVC: { REL: 0x50 },
1010
+ BVS: { REL: 0x70 },
1011
+ CLC: { IMP: 0x18 },
1012
+ CLD: { IMP: 0xd8 },
1013
+ CLI: { IMP: 0x58 },
1014
+ CLV: { IMP: 0xb8 },
1015
+ CMP: {
1016
+ IMM: 0xc9,
1017
+ ZP: 0xc5,
1018
+ ZPX: 0xd5,
1019
+ ABS: 0xcd,
1020
+ ABX: 0xdd,
1021
+ ABY: 0xd9,
1022
+ IZX: 0xc1,
1023
+ IZY: 0xd1,
1024
+ ZPI: 0xd2,
1025
+ },
1026
+ CPX: { IMM: 0xe0, ZP: 0xe4, ABS: 0xec },
1027
+ CPY: { IMM: 0xc0, ZP: 0xc4, ABS: 0xcc },
1028
+ DEC: { IMP: 0x3a, ACC: 0x3a, ZP: 0xc6, ZPX: 0xd6, ABS: 0xce, ABX: 0xde },
1029
+ DEA: { IMP: 0x3a },
1030
+ DEX: { IMP: 0xca },
1031
+ DEY: { IMP: 0x88 },
1032
+ EOR: {
1033
+ IMM: 0x49,
1034
+ ZP: 0x45,
1035
+ ZPX: 0x55,
1036
+ ABS: 0x4d,
1037
+ ABX: 0x5d,
1038
+ ABY: 0x59,
1039
+ IZX: 0x41,
1040
+ IZY: 0x51,
1041
+ ZPI: 0x52,
1042
+ },
1043
+ INC: { IMP: 0x1a, ACC: 0x1a, ZP: 0xe6, ZPX: 0xf6, ABS: 0xee, ABX: 0xfe },
1044
+ INA: { IMP: 0x1a },
1045
+ INX: { IMP: 0xe8 },
1046
+ INY: { IMP: 0xc8 },
1047
+ JMP: { ABS: 0x4c, IND: 0x6c, IAX: 0x7c },
1048
+ JSR: { ABS: 0x20 },
1049
+ LDA: {
1050
+ IMM: 0xa9,
1051
+ ZP: 0xa5,
1052
+ ZPX: 0xb5,
1053
+ ABS: 0xad,
1054
+ ABX: 0xbd,
1055
+ ABY: 0xb9,
1056
+ IZX: 0xa1,
1057
+ IZY: 0xb1,
1058
+ ZPI: 0xb2,
1059
+ },
1060
+ LDX: { IMM: 0xa2, ZP: 0xa6, ZPY: 0xb6, ABS: 0xae, ABY: 0xbe },
1061
+ LDY: { IMM: 0xa0, ZP: 0xa4, ZPX: 0xb4, ABS: 0xac, ABX: 0xbc },
1062
+ LSR: { IMP: 0x4a, ACC: 0x4a, ZP: 0x46, ZPX: 0x56, ABS: 0x4e, ABX: 0x5e },
1063
+ NOP: { IMP: 0xea },
1064
+ ORA: {
1065
+ IMM: 0x09,
1066
+ ZP: 0x05,
1067
+ ZPX: 0x15,
1068
+ ABS: 0x0d,
1069
+ ABX: 0x1d,
1070
+ ABY: 0x19,
1071
+ IZX: 0x01,
1072
+ IZY: 0x11,
1073
+ ZPI: 0x12,
1074
+ },
1075
+ PHA: { IMP: 0x48 },
1076
+ PHP: { IMP: 0x08 },
1077
+ PHX: { IMP: 0xda },
1078
+ PHY: { IMP: 0x5a },
1079
+ PLA: { IMP: 0x68 },
1080
+ PLP: { IMP: 0x28 },
1081
+ PLX: { IMP: 0xfa },
1082
+ PLY: { IMP: 0x7a },
1083
+ ROL: { IMP: 0x2a, ACC: 0x2a, ZP: 0x26, ZPX: 0x36, ABS: 0x2e, ABX: 0x3e },
1084
+ ROR: { IMP: 0x6a, ACC: 0x6a, ZP: 0x66, ZPX: 0x76, ABS: 0x6e, ABX: 0x7e },
1085
+ RTI: { IMP: 0x40 },
1086
+ RTS: { IMP: 0x60 },
1087
+ SBC: {
1088
+ IMM: 0xe9,
1089
+ ZP: 0xe5,
1090
+ ZPX: 0xf5,
1091
+ ABS: 0xed,
1092
+ ABX: 0xfd,
1093
+ ABY: 0xf9,
1094
+ IZX: 0xe1,
1095
+ IZY: 0xf1,
1096
+ ZPI: 0xf2,
1097
+ },
1098
+ SEC: { IMP: 0x38 },
1099
+ SED: { IMP: 0xf8 },
1100
+ SEI: { IMP: 0x78 },
1101
+ STA: {
1102
+ ZP: 0x85,
1103
+ ZPX: 0x95,
1104
+ ABS: 0x8d,
1105
+ ABX: 0x9d,
1106
+ ABY: 0x99,
1107
+ IZX: 0x81,
1108
+ IZY: 0x91,
1109
+ ZPI: 0x92,
1110
+ },
1111
+ STX: { ZP: 0x86, ZPY: 0x96, ABS: 0x8e },
1112
+ STY: { ZP: 0x84, ZPX: 0x94, ABS: 0x8c },
1113
+ STZ: { ZP: 0x64, ZPX: 0x74, ABS: 0x9c, ABX: 0x9e },
1114
+ TAX: { IMP: 0xaa },
1115
+ TAY: { IMP: 0xa8 },
1116
+ TRB: { ZP: 0x14, ABS: 0x1c },
1117
+ TSB: { ZP: 0x04, ABS: 0x0c },
1118
+ TSX: { IMP: 0xba },
1119
+ TXA: { IMP: 0x8a },
1120
+ TXS: { IMP: 0x9a },
1121
+ TYA: { IMP: 0x98 },
1122
+ WAI: { IMP: 0xcb },
1123
+ STP: { IMP: 0xdb },
1124
+ // BBR/BBS (zero page relative - 3 bytes)
1125
+ BBR0: { ZPR: 0x0f },
1126
+ BBR1: { ZPR: 0x1f },
1127
+ BBR2: { ZPR: 0x2f },
1128
+ BBR3: { ZPR: 0x3f },
1129
+ BBR4: { ZPR: 0x4f },
1130
+ BBR5: { ZPR: 0x5f },
1131
+ BBR6: { ZPR: 0x6f },
1132
+ BBR7: { ZPR: 0x7f },
1133
+ BBS0: { ZPR: 0x8f },
1134
+ BBS1: { ZPR: 0x9f },
1135
+ BBS2: { ZPR: 0xaf },
1136
+ BBS3: { ZPR: 0xbf },
1137
+ BBS4: { ZPR: 0xcf },
1138
+ BBS5: { ZPR: 0xdf },
1139
+ BBS6: { ZPR: 0xef },
1140
+ BBS7: { ZPR: 0xff },
1141
+ // RMB/SMB (zero page - 2 bytes)
1142
+ RMB0: { ZP: 0x07 },
1143
+ RMB1: { ZP: 0x17 },
1144
+ RMB2: { ZP: 0x27 },
1145
+ RMB3: { ZP: 0x37 },
1146
+ RMB4: { ZP: 0x47 },
1147
+ RMB5: { ZP: 0x57 },
1148
+ RMB6: { ZP: 0x67 },
1149
+ RMB7: { ZP: 0x77 },
1150
+ SMB0: { ZP: 0x87 },
1151
+ SMB1: { ZP: 0x97 },
1152
+ SMB2: { ZP: 0xa7 },
1153
+ SMB3: { ZP: 0xb7 },
1154
+ SMB4: { ZP: 0xc7 },
1155
+ SMB5: { ZP: 0xd7 },
1156
+ SMB6: { ZP: 0xe7 },
1157
+ SMB7: { ZP: 0xf7 },
1158
+ };
1159
+ }
1160
+
1161
+ /**
1162
+ * Parse an operand and determine addressing mode + value
1163
+ * Returns { mode, value, value2 } or null if unparseable
1164
+ */
1165
+ parseOperand(operand, mnemonic) {
1166
+ if (!operand || operand.trim() === "") {
1167
+ return { mode: "IMP", value: null };
1168
+ }
1169
+
1170
+ operand = operand.trim();
1171
+ const opcodes = this.getOpcodeTable()[mnemonic];
1172
+ if (!opcodes) return null;
1173
+
1174
+ // Immediate: #$xx or #value
1175
+ if (operand.startsWith("#")) {
1176
+ const val = this.parseValue(operand.substring(1));
1177
+ if (val !== null) {
1178
+ return { mode: "IMM", value: val & 0xff };
1179
+ }
1180
+ return null; // Unresolved symbol
1181
+ }
1182
+
1183
+ // Indirect modes
1184
+ if (operand.startsWith("(")) {
1185
+ // (addr,X) - Indexed indirect
1186
+ if (operand.match(/^\([^)]+,\s*X\)$/i)) {
1187
+ const inner = operand.match(/^\(([^,]+),/i)[1];
1188
+ const val = this.parseValue(inner);
1189
+ if (val !== null) return { mode: "IZX", value: val & 0xff };
1190
+ return null;
1191
+ }
1192
+ // (addr),Y - Indirect indexed
1193
+ if (operand.match(/^\([^)]+\)\s*,\s*Y$/i)) {
1194
+ const inner = operand.match(/^\(([^)]+)\)/i)[1];
1195
+ const val = this.parseValue(inner);
1196
+ if (val !== null) return { mode: "IZY", value: val & 0xff };
1197
+ return null;
1198
+ }
1199
+ // (addr,X) for JMP
1200
+ if (operand.match(/^\([^)]+,\s*X\)$/i) && opcodes.IAX) {
1201
+ const inner = operand.match(/^\(([^,]+),/i)[1];
1202
+ const val = this.parseValue(inner);
1203
+ if (val !== null) return { mode: "IAX", value: val & 0xffff };
1204
+ return null;
1205
+ }
1206
+ // (addr) - Indirect (JMP) or Zero Page Indirect (65C02)
1207
+ if (operand.match(/^\([^)]+\)$/)) {
1208
+ const inner = operand.match(/^\(([^)]+)\)$/)[1];
1209
+ const val = this.parseValue(inner);
1210
+ if (val !== null) {
1211
+ if (opcodes.IND && val > 0xff)
1212
+ return { mode: "IND", value: val & 0xffff };
1213
+ if (opcodes.ZPI) return { mode: "ZPI", value: val & 0xff };
1214
+ if (opcodes.IND) return { mode: "IND", value: val & 0xffff };
1215
+ }
1216
+ return null;
1217
+ }
1218
+ }
1219
+
1220
+ // addr,X or addr,Y
1221
+ if (operand.match(/,\s*X$/i)) {
1222
+ const addrPart = operand.replace(/,\s*X$/i, "").trim();
1223
+ const val = this.parseValue(addrPart);
1224
+ if (val !== null) {
1225
+ if (val <= 0xff && opcodes.ZPX) return { mode: "ZPX", value: val };
1226
+ if (opcodes.ABX) return { mode: "ABX", value: val & 0xffff };
1227
+ }
1228
+ return null;
1229
+ }
1230
+ if (operand.match(/,\s*Y$/i)) {
1231
+ const addrPart = operand.replace(/,\s*Y$/i, "").trim();
1232
+ const val = this.parseValue(addrPart);
1233
+ if (val !== null) {
1234
+ if (val <= 0xff && opcodes.ZPY) return { mode: "ZPY", value: val };
1235
+ if (opcodes.ABY) return { mode: "ABY", value: val & 0xffff };
1236
+ }
1237
+ return null;
1238
+ }
1239
+
1240
+ // Accumulator mode (A or empty for shift/rotate)
1241
+ if (operand.toUpperCase() === "A" && opcodes.ACC) {
1242
+ return { mode: "ACC", value: null };
1243
+ }
1244
+
1245
+ // Branch relative - just parse the target, we'll show ?? for offset
1246
+ if (opcodes.REL) {
1247
+ const val = this.parseValue(operand);
1248
+ // For branches, we can't calculate offset without knowing current PC
1249
+ // Just return the mode with the target value
1250
+ return { mode: "REL", value: val };
1251
+ }
1252
+
1253
+ // Plain address - zero page or absolute
1254
+ const val = this.parseValue(operand);
1255
+ if (val !== null) {
1256
+ if (val <= 0xff && opcodes.ZP) return { mode: "ZP", value: val };
1257
+ if (opcodes.ABS) return { mode: "ABS", value: val & 0xffff };
1258
+ }
1259
+
1260
+ return null; // Unresolved
1261
+ }
1262
+
1263
+ /**
1264
+ * Parse a value or expression. Supports arithmetic (+, -, *, /),
1265
+ * byte selectors (< >), current PC (*), and nested parentheses.
1266
+ */
1267
+ parseValue(str) {
1268
+ if (!str) return null;
1269
+ str = str.trim();
1270
+ if (!str) return null;
1271
+ this._exprPos = 0;
1272
+ this._exprStr = str;
1273
+ const val = this._exprAddSub();
1274
+ return val;
1275
+ }
1276
+
1277
+ _exprSkipSpaces() {
1278
+ while (
1279
+ this._exprPos < this._exprStr.length &&
1280
+ this._exprStr[this._exprPos] === " "
1281
+ ) {
1282
+ this._exprPos++;
1283
+ }
1284
+ }
1285
+
1286
+ _exprPeek() {
1287
+ this._exprSkipSpaces();
1288
+ return this._exprPos < this._exprStr.length
1289
+ ? this._exprStr[this._exprPos]
1290
+ : null;
1291
+ }
1292
+
1293
+ _exprAddSub() {
1294
+ let val = this._exprMulDiv();
1295
+ if (val === null) return null;
1296
+ while (true) {
1297
+ const ch = this._exprPeek();
1298
+ if (ch === "+") {
1299
+ this._exprPos++;
1300
+ const right = this._exprMulDiv();
1301
+ if (right === null) return null;
1302
+ val = val + right;
1303
+ } else if (ch === "-") {
1304
+ this._exprPos++;
1305
+ const right = this._exprMulDiv();
1306
+ if (right === null) return null;
1307
+ val = val - right;
1308
+ } else {
1309
+ break;
1310
+ }
1311
+ }
1312
+ return val;
1313
+ }
1314
+
1315
+ _exprMulDiv() {
1316
+ let val = this._exprUnary();
1317
+ if (val === null) return null;
1318
+ while (true) {
1319
+ const ch = this._exprPeek();
1320
+ // * here is always multiply — PC reference is handled in _exprPrimary
1321
+ if (ch === "*") {
1322
+ this._exprPos++;
1323
+ const right = this._exprUnary();
1324
+ if (right === null) return null;
1325
+ val = val * right;
1326
+ } else if (ch === "/") {
1327
+ this._exprPos++;
1328
+ const right = this._exprUnary();
1329
+ if (right === null) return null;
1330
+ val = right !== 0 ? Math.trunc(val / right) : 0;
1331
+ } else {
1332
+ break;
1333
+ }
1334
+ }
1335
+ return val;
1336
+ }
1337
+
1338
+ _exprUnary() {
1339
+ const ch = this._exprPeek();
1340
+ if (ch === "<") {
1341
+ this._exprPos++;
1342
+ const val = this._exprUnary();
1343
+ return val !== null ? val & 0xff : null;
1344
+ }
1345
+ if (ch === ">") {
1346
+ this._exprPos++;
1347
+ const val = this._exprUnary();
1348
+ return val !== null ? (val >> 8) & 0xff : null;
1349
+ }
1350
+ if (ch === "-") {
1351
+ this._exprPos++;
1352
+ const val = this._exprUnary();
1353
+ return val !== null ? -val : null;
1354
+ }
1355
+ return this._exprPrimary();
1356
+ }
1357
+
1358
+ _exprPrimary() {
1359
+ this._exprSkipSpaces();
1360
+ if (this._exprPos >= this._exprStr.length) return null;
1361
+ const ch = this._exprStr[this._exprPos];
1362
+
1363
+ // Parenthesized sub-expression
1364
+ if (ch === "(") {
1365
+ this._exprPos++;
1366
+ const val = this._exprAddSub();
1367
+ this._exprSkipSpaces();
1368
+ if (
1369
+ this._exprPos < this._exprStr.length &&
1370
+ this._exprStr[this._exprPos] === ")"
1371
+ ) {
1372
+ this._exprPos++;
1373
+ }
1374
+ return val;
1375
+ }
1376
+
1377
+ // Current PC: *
1378
+ if (ch === "*") {
1379
+ this._exprPos++;
1380
+ return this.currentPC !== undefined ? this.currentPC : null;
1381
+ }
1382
+
1383
+ // Hex: $xxxx
1384
+ if (ch === "$") {
1385
+ this._exprPos++;
1386
+ let start = this._exprPos;
1387
+ while (
1388
+ this._exprPos < this._exprStr.length &&
1389
+ /[0-9A-Fa-f]/.test(this._exprStr[this._exprPos])
1390
+ ) {
1391
+ this._exprPos++;
1392
+ }
1393
+ if (this._exprPos === start) return null;
1394
+ return parseInt(this._exprStr.substring(start, this._exprPos), 16);
1395
+ }
1396
+
1397
+ // Binary: %01010101
1398
+ if (ch === "%") {
1399
+ this._exprPos++;
1400
+ let start = this._exprPos;
1401
+ while (
1402
+ this._exprPos < this._exprStr.length &&
1403
+ /[01]/.test(this._exprStr[this._exprPos])
1404
+ ) {
1405
+ this._exprPos++;
1406
+ }
1407
+ if (this._exprPos === start) return null;
1408
+ return parseInt(this._exprStr.substring(start, this._exprPos), 2);
1409
+ }
1410
+
1411
+ // Character literal: 'A'
1412
+ if (ch === "'") {
1413
+ this._exprPos++;
1414
+ if (this._exprPos >= this._exprStr.length) return null;
1415
+ const val = this._exprStr.charCodeAt(this._exprPos);
1416
+ this._exprPos++;
1417
+ if (
1418
+ this._exprPos < this._exprStr.length &&
1419
+ this._exprStr[this._exprPos] === "'"
1420
+ ) {
1421
+ this._exprPos++;
1422
+ }
1423
+ return val;
1424
+ }
1425
+
1426
+ // Decimal number
1427
+ if (/[0-9]/.test(ch)) {
1428
+ let start = this._exprPos;
1429
+ while (
1430
+ this._exprPos < this._exprStr.length &&
1431
+ /[0-9]/.test(this._exprStr[this._exprPos])
1432
+ ) {
1433
+ this._exprPos++;
1434
+ }
1435
+ return parseInt(this._exprStr.substring(start, this._exprPos), 10);
1436
+ }
1437
+
1438
+ // Symbol / label
1439
+ if (/[A-Za-z_:\]]/.test(ch)) {
1440
+ let start = this._exprPos;
1441
+ while (
1442
+ this._exprPos < this._exprStr.length &&
1443
+ /[A-Za-z0-9_:\]]/.test(this._exprStr[this._exprPos])
1444
+ ) {
1445
+ this._exprPos++;
1446
+ }
1447
+ const name = this._exprStr.substring(start, this._exprPos).toUpperCase();
1448
+ if (this.symbols.has(name)) {
1449
+ return this.symbols.get(name);
1450
+ }
1451
+ return null; // Unresolved symbol
1452
+ }
1453
+
1454
+ return null;
1455
+ }
1456
+
1457
+ /**
1458
+ * Encode a line and store the bytes
1459
+ */
1460
+ encodeLineBytes(lineNumber) {
1461
+ if (!this.textarea) return;
1462
+
1463
+ const lines = this.textarea.value.split("\n");
1464
+ if (lineNumber < 1 || lineNumber > lines.length) return;
1465
+
1466
+ const line = lines[lineNumber - 1];
1467
+ const parsed = this.parseLine(line);
1468
+
1469
+ if (!parsed || !parsed.opcode) {
1470
+ this.lineBytes.delete(lineNumber);
1471
+ return;
1472
+ }
1473
+
1474
+ const mnemonic = parsed.opcode.toUpperCase();
1475
+ const opcodes = this.getOpcodeTable()[mnemonic];
1476
+
1477
+ // Skip directives - they don't have opcodes in our table
1478
+ if (!opcodes) {
1479
+ this.lineBytes.delete(lineNumber);
1480
+ return;
1481
+ }
1482
+
1483
+ const operandInfo = this.parseOperand(parsed.operand, mnemonic);
1484
+ if (!operandInfo) {
1485
+ this.lineBytes.delete(lineNumber);
1486
+ return;
1487
+ }
1488
+
1489
+ const opcode = opcodes[operandInfo.mode];
1490
+ if (opcode === undefined) {
1491
+ this.lineBytes.delete(lineNumber);
1492
+ return;
1493
+ }
1494
+
1495
+ // Build the byte string
1496
+ let bytes = opcode.toString(16).toUpperCase().padStart(2, "0");
1497
+
1498
+ if (operandInfo.mode === "IMP" || operandInfo.mode === "ACC") {
1499
+ // 1 byte - just the opcode
1500
+ } else if (operandInfo.mode === "REL") {
1501
+ // Branch - calculate relative offset if we know target and current PC
1502
+ const targetAddr = operandInfo.value;
1503
+ const currentPC = this.linePCs?.get(lineNumber);
1504
+
1505
+ if (targetAddr !== null && currentPC !== undefined) {
1506
+ // Branch offset is relative to PC after the instruction (PC + 2)
1507
+ const nextPC = currentPC + 2;
1508
+ const offset = targetAddr - nextPC;
1509
+
1510
+ // Check if offset is in valid range (-128 to +127)
1511
+ if (offset >= -128 && offset <= 127) {
1512
+ const signedByte = offset < 0 ? 256 + offset : offset;
1513
+ bytes += " " + signedByte.toString(16).toUpperCase().padStart(2, "0");
1514
+ } else {
1515
+ bytes += " ??"; // Out of range
1516
+ }
1517
+ } else {
1518
+ bytes += " ??"; // Unknown target
1519
+ }
1520
+ } else if (
1521
+ ["IMM", "ZP", "ZPX", "ZPY", "IZX", "IZY", "ZPI"].includes(
1522
+ operandInfo.mode,
1523
+ )
1524
+ ) {
1525
+ // 2 bytes
1526
+ if (operandInfo.value !== null) {
1527
+ bytes +=
1528
+ " " +
1529
+ (operandInfo.value & 0xff)
1530
+ .toString(16)
1531
+ .toUpperCase()
1532
+ .padStart(2, "0");
1533
+ } else {
1534
+ bytes += " ??";
1535
+ }
1536
+ } else if (["ABS", "ABX", "ABY", "IND", "IAX"].includes(operandInfo.mode)) {
1537
+ // 3 bytes (little-endian)
1538
+ if (operandInfo.value !== null) {
1539
+ const lo = operandInfo.value & 0xff;
1540
+ const hi = (operandInfo.value >> 8) & 0xff;
1541
+ bytes += " " + lo.toString(16).toUpperCase().padStart(2, "0");
1542
+ bytes += " " + hi.toString(16).toUpperCase().padStart(2, "0");
1543
+ } else {
1544
+ bytes += " ?? ??";
1545
+ }
1546
+ } else if (operandInfo.mode === "ZPR") {
1547
+ // 3 bytes: opcode, zp addr, relative offset
1548
+ bytes += " ?? ??";
1549
+ }
1550
+
1551
+ this.lineBytes.set(lineNumber, bytes);
1552
+ }
1553
+
1554
+ /**
1555
+ * Encode all lines (called after successful assembly)
1556
+ */
1557
+ encodeAllLineBytes() {
1558
+ this.lineBytes.clear();
1559
+ this.linePCs = new Map(); // Track PC for each line
1560
+
1561
+ const lines = this.textarea.value.split("\n");
1562
+
1563
+ // Find ORG from source code, default to $0800 if not found yet
1564
+ let pc = 0x0800;
1565
+
1566
+ // First pass: calculate PC and collect labels/EQU values
1567
+ const localSymbols = new Map();
1568
+ for (let i = 0; i < lines.length; i++) {
1569
+ const lineNumber = i + 1;
1570
+ const parsed = this.parseLine(lines[i]);
1571
+
1572
+ if (parsed && parsed.opcode) {
1573
+ const mnem = parsed.opcode.toUpperCase();
1574
+
1575
+ // Check for ORG directive and update PC
1576
+ if (mnem === "ORG") {
1577
+ this.currentPC = pc;
1578
+ const orgValue = this.parseValue(parsed.operand);
1579
+ if (orgValue !== null) {
1580
+ pc = orgValue;
1581
+ }
1582
+ }
1583
+
1584
+ // Collect label addresses and EQU values
1585
+ if (parsed.label) {
1586
+ const labelUpper = parsed.label.toUpperCase();
1587
+ if (mnem === "EQU") {
1588
+ this.currentPC = pc;
1589
+ const val = this.parseValue(parsed.operand);
1590
+ if (val !== null) {
1591
+ localSymbols.set(labelUpper, val);
1592
+ this.symbols.set(labelUpper, val);
1593
+ }
1594
+ } else {
1595
+ localSymbols.set(labelUpper, pc);
1596
+ this.symbols.set(labelUpper, pc);
1597
+ }
1598
+ }
1599
+ } else if (parsed && parsed.label) {
1600
+ // Label-only line (no opcode)
1601
+ const labelUpper = parsed.label.toUpperCase();
1602
+ localSymbols.set(labelUpper, pc);
1603
+ this.symbols.set(labelUpper, pc);
1604
+ }
1605
+
1606
+ this.linePCs.set(lineNumber, pc);
1607
+
1608
+ if (parsed && parsed.opcode) {
1609
+ const size = this.getInstructionSize(
1610
+ parsed.opcode.toUpperCase(),
1611
+ parsed.operand,
1612
+ );
1613
+ pc += size;
1614
+ }
1615
+ }
1616
+
1617
+ // Second pass: re-evaluate EQU values now that all labels are known
1618
+ pc = 0x0800;
1619
+ for (let i = 0; i < lines.length; i++) {
1620
+ const parsed = this.parseLine(lines[i]);
1621
+ if (parsed && parsed.opcode) {
1622
+ const mnem = parsed.opcode.toUpperCase();
1623
+ if (mnem === "ORG") {
1624
+ this.currentPC = pc;
1625
+ const orgValue = this.parseValue(parsed.operand);
1626
+ if (orgValue !== null) pc = orgValue;
1627
+ }
1628
+ if (parsed.label && mnem === "EQU") {
1629
+ this.currentPC = pc;
1630
+ const val = this.parseValue(parsed.operand);
1631
+ if (val !== null) {
1632
+ this.symbols.set(parsed.label.toUpperCase(), val);
1633
+ }
1634
+ }
1635
+ }
1636
+ this.currentPC = this.linePCs.get(i + 1);
1637
+ if (parsed && parsed.opcode) {
1638
+ const size = this.getInstructionSize(
1639
+ parsed.opcode.toUpperCase(),
1640
+ parsed.operand,
1641
+ );
1642
+ pc += size;
1643
+ }
1644
+ }
1645
+
1646
+ // Third pass: encode with known PCs and symbols
1647
+ for (let i = 1; i <= lines.length; i++) {
1648
+ this.currentPC = this.linePCs.get(i);
1649
+ this.encodeLineBytes(i);
1650
+ }
1651
+ this.currentPC = undefined;
1652
+ }
1653
+
1654
+ /**
1655
+ * Get the size of an instruction in bytes
1656
+ */
1657
+ getInstructionSize(mnemonic, operand) {
1658
+ const opcodes = this.getOpcodeTable()[mnemonic];
1659
+ if (!opcodes) {
1660
+ // Check if it's a directive
1661
+ const upper = mnemonic.toUpperCase();
1662
+ if (upper === "ORG" || upper === "EQU") return 0;
1663
+ if (upper === "DFB" || upper === "DB") {
1664
+ // Count comma-separated values
1665
+ if (!operand) return 1;
1666
+ return operand.split(",").length;
1667
+ }
1668
+ if (upper === "DW" || upper === "DA") {
1669
+ if (!operand) return 2;
1670
+ return operand.split(",").length * 2;
1671
+ }
1672
+ if (upper === "ASC" || upper === "DCI") {
1673
+ // String length (rough estimate)
1674
+ const match = operand?.match(/["']([^"']*)["']/);
1675
+ if (match) return match[1].length;
1676
+ return 0;
1677
+ }
1678
+ if (upper === "DS") {
1679
+ const val = this.parseValue(operand);
1680
+ return val || 0;
1681
+ }
1682
+ if (upper === "HEX") {
1683
+ // Count hex digits / 2
1684
+ const hex = operand?.replace(/[^0-9A-Fa-f]/g, "") || "";
1685
+ return Math.floor(hex.length / 2);
1686
+ }
1687
+ return 0;
1688
+ }
1689
+
1690
+ const operandInfo = this.parseOperand(operand, mnemonic);
1691
+ if (!operandInfo) return 0;
1692
+
1693
+ // Size based on addressing mode
1694
+ switch (operandInfo.mode) {
1695
+ case "IMP":
1696
+ case "ACC":
1697
+ return 1;
1698
+ case "IMM":
1699
+ case "ZP":
1700
+ case "ZPX":
1701
+ case "ZPY":
1702
+ case "IZX":
1703
+ case "IZY":
1704
+ case "ZPI":
1705
+ case "REL":
1706
+ return 2;
1707
+ case "ABS":
1708
+ case "ABX":
1709
+ case "ABY":
1710
+ case "IND":
1711
+ case "IAX":
1712
+ case "ZPR":
1713
+ return 3;
1714
+ default:
1715
+ return 1;
1716
+ }
1717
+ }
1718
+
1719
+ /**
1720
+ * Validate a line for syntax errors (stray characters, malformed syntax)
1721
+ */
1722
+ validateLine(lineNumber) {
1723
+ if (!this.textarea) return;
1724
+
1725
+ const lines = this.textarea.value.split("\n");
1726
+ if (lineNumber < 1 || lineNumber > lines.length) return;
1727
+
1728
+ const line = lines[lineNumber - 1];
1729
+
1730
+ // Clear any previous syntax error for this line
1731
+ this.syntaxErrors.delete(lineNumber);
1732
+
1733
+ // Empty lines are valid
1734
+ if (!line.trim()) return;
1735
+
1736
+ // Full-line comments are valid
1737
+ const trimmed = line.trim();
1738
+ if (trimmed.startsWith(";") || trimmed.startsWith("*")) return;
1739
+
1740
+ // Parse the line to validate structure
1741
+ const error = this.checkLineSyntax(line);
1742
+ if (error) {
1743
+ this.syntaxErrors.set(lineNumber, error);
1744
+ }
1745
+ }
1746
+
1747
+ /**
1748
+ * Check line syntax and return error message or null if valid
1749
+ */
1750
+ checkLineSyntax(line) {
1751
+ // Extract comment first (respecting quotes)
1752
+ let commentIdx = -1;
1753
+ let inQuote = false;
1754
+ let quoteChar = null;
1755
+ for (let i = 0; i < line.length; i++) {
1756
+ const ch = line[i];
1757
+ if ((ch === '"' || ch === "'") && !inQuote) {
1758
+ inQuote = true;
1759
+ quoteChar = ch;
1760
+ } else if (ch === quoteChar && inQuote) {
1761
+ inQuote = false;
1762
+ quoteChar = null;
1763
+ } else if (ch === ";" && !inQuote) {
1764
+ commentIdx = i;
1765
+ break;
1766
+ }
1767
+ }
1768
+
1769
+ const mainPart = commentIdx >= 0 ? line.substring(0, commentIdx) : line;
1770
+
1771
+ // If line is just whitespace before comment, that's valid
1772
+ if (!mainPart.trim()) return null;
1773
+
1774
+ // Check if line starts with whitespace (no label)
1775
+ const hasLabel =
1776
+ mainPart.length > 0 && mainPart[0] !== " " && mainPart[0] !== "\t";
1777
+
1778
+ // Tokenize the main part
1779
+ const tokens = [];
1780
+ let current = "";
1781
+ let inStr = false;
1782
+ let strChar = null;
1783
+
1784
+ for (let i = 0; i < mainPart.length; i++) {
1785
+ const ch = mainPart[i];
1786
+
1787
+ if ((ch === '"' || ch === "'") && !inStr) {
1788
+ inStr = true;
1789
+ strChar = ch;
1790
+ current += ch;
1791
+ } else if (ch === strChar && inStr) {
1792
+ inStr = false;
1793
+ strChar = null;
1794
+ current += ch;
1795
+ } else if ((ch === " " || ch === "\t") && !inStr) {
1796
+ if (current) {
1797
+ tokens.push({ text: current, pos: i - current.length });
1798
+ current = "";
1799
+ }
1800
+ } else {
1801
+ current += ch;
1802
+ }
1803
+ }
1804
+ if (current) {
1805
+ tokens.push({ text: current, pos: mainPart.length - current.length });
1806
+ }
1807
+
1808
+ if (tokens.length === 0) return null;
1809
+
1810
+ // Validate token count based on whether there's a label
1811
+ // Valid patterns:
1812
+ // - Label only: 1 token at col 0
1813
+ // - Opcode only: 1 token not at col 0
1814
+ // - Label + Opcode: 2 tokens, first at col 0
1815
+ // - Opcode + Operand: 2 tokens, first not at col 0
1816
+ // - Label + Opcode + Operand: 3 tokens, first at col 0
1817
+
1818
+ const firstAtCol0 = tokens[0].pos === 0;
1819
+
1820
+ if (hasLabel) {
1821
+ // First token is label
1822
+ if (tokens.length === 1) {
1823
+ // Just a label - valid
1824
+ return null;
1825
+ } else if (tokens.length === 2) {
1826
+ // Label + opcode OR label + something invalid
1827
+ if (!this.isValidOpcode(tokens[1].text)) {
1828
+ return `Unknown mnemonic or directive: ${tokens[1].text}`;
1829
+ }
1830
+ return null;
1831
+ } else if (tokens.length === 3) {
1832
+ // Label + opcode + operand
1833
+ if (!this.isValidOpcode(tokens[1].text)) {
1834
+ return `Unknown mnemonic or directive: ${tokens[1].text}`;
1835
+ }
1836
+ // Validate operand for invalid numbers
1837
+ const operandError = this.validateOperandNumbers(
1838
+ tokens[1].text,
1839
+ tokens[2].text,
1840
+ );
1841
+ if (operandError) return operandError;
1842
+ return null;
1843
+ } else if (tokens.length > 3) {
1844
+ // Too many tokens - find the extra one
1845
+ const extra = tokens
1846
+ .slice(3)
1847
+ .map((t) => t.text)
1848
+ .join(" ");
1849
+ return `Unexpected: ${extra}`;
1850
+ }
1851
+ } else {
1852
+ // No label - first token should be opcode
1853
+ if (tokens.length === 1) {
1854
+ // Just opcode
1855
+ if (!this.isValidOpcode(tokens[0].text)) {
1856
+ return `Unknown mnemonic or directive: ${tokens[0].text}`;
1857
+ }
1858
+ return null;
1859
+ } else if (tokens.length === 2) {
1860
+ // Opcode + operand
1861
+ if (!this.isValidOpcode(tokens[0].text)) {
1862
+ return `Unknown mnemonic or directive: ${tokens[0].text}`;
1863
+ }
1864
+ // Validate operand for invalid numbers
1865
+ const operandError = this.validateOperandNumbers(
1866
+ tokens[0].text,
1867
+ tokens[1].text,
1868
+ );
1869
+ if (operandError) return operandError;
1870
+ return null;
1871
+ } else if (tokens.length > 2) {
1872
+ // Too many tokens
1873
+ const extra = tokens
1874
+ .slice(2)
1875
+ .map((t) => t.text)
1876
+ .join(" ");
1877
+ return `Unexpected: ${extra}`;
1878
+ }
1879
+ }
1880
+
1881
+ return null;
1882
+ }
1883
+
1884
+ /**
1885
+ * Validate numeric literals in an operand
1886
+ * Returns error message or null if valid
1887
+ */
1888
+ validateOperandNumbers(opcode, operand) {
1889
+ // Skip string literals
1890
+ if (operand.startsWith('"') || operand.startsWith("'")) {
1891
+ return null;
1892
+ }
1893
+
1894
+ const upperOpcode = opcode.toUpperCase();
1895
+
1896
+ // Directives that expect 8-bit values
1897
+ const byteDirectives = new Set(["DFB", "DB"]);
1898
+
1899
+ // Check if this is immediate mode (starts with #)
1900
+ const isImmediate = operand.startsWith("#");
1901
+
1902
+ // Determine max value based on context
1903
+ // Immediate mode and byte directives expect 8-bit values
1904
+ const expects8Bit = isImmediate || byteDirectives.has(upperOpcode);
1905
+
1906
+ // Find all hex numbers ($xxxx) and validate them
1907
+ const hexPattern = /\$([A-Za-z0-9]+)/g;
1908
+ let match;
1909
+ while ((match = hexPattern.exec(operand)) !== null) {
1910
+ const hexDigits = match[1];
1911
+ if (!/^[0-9A-Fa-f]+$/.test(hexDigits)) {
1912
+ return `Invalid hex number: $${hexDigits}`;
1913
+ }
1914
+ // Check value range
1915
+ const value = parseInt(hexDigits, 16);
1916
+ if (value > 0xffff) {
1917
+ return `Value exceeds 16-bit maximum: $${hexDigits}`;
1918
+ }
1919
+ if (expects8Bit && value > 0xff) {
1920
+ return `Value exceeds 8-bit maximum: $${hexDigits}`;
1921
+ }
1922
+ }
1923
+
1924
+ // Find all binary numbers (%xxxx) and validate them
1925
+ const binPattern = /%([A-Za-z0-9]+)/g;
1926
+ while ((match = binPattern.exec(operand)) !== null) {
1927
+ const binDigits = match[1];
1928
+ if (!/^[01]+$/.test(binDigits)) {
1929
+ return `Invalid binary number: %${binDigits}`;
1930
+ }
1931
+ // Check value range
1932
+ const value = parseInt(binDigits, 2);
1933
+ if (value > 0xffff) {
1934
+ return `Value exceeds 16-bit maximum: %${binDigits}`;
1935
+ }
1936
+ if (expects8Bit && value > 0xff) {
1937
+ return `Value exceeds 8-bit maximum: %${binDigits}`;
1938
+ }
1939
+ }
1940
+
1941
+ // Find decimal numbers (digits not preceded by $ or %)
1942
+ const decPattern = /(?<![A-Za-z0-9$%])(\d+)(?![A-Za-z])/g;
1943
+ while ((match = decPattern.exec(operand)) !== null) {
1944
+ const decDigits = match[1];
1945
+ const value = parseInt(decDigits, 10);
1946
+ if (value > 0xffff) {
1947
+ return `Value exceeds 16-bit maximum: ${decDigits}`;
1948
+ }
1949
+ if (expects8Bit && value > 0xff) {
1950
+ return `Value exceeds 8-bit maximum: ${decDigits}`;
1951
+ }
1952
+ }
1953
+
1954
+ return null;
1955
+ }
1956
+
1957
+ /**
1958
+ * Check if a token is a valid opcode/mnemonic or directive
1959
+ */
1960
+ isValidOpcode(token) {
1961
+ const upper = token.toUpperCase();
1962
+
1963
+ // Check against opcode table
1964
+ if (this.getOpcodeTable()[upper]) return true;
1965
+
1966
+ // Check common directives
1967
+ const directives = new Set([
1968
+ "ORG",
1969
+ "EQU",
1970
+ "DS",
1971
+ "DFB",
1972
+ "DB",
1973
+ "DW",
1974
+ "DA",
1975
+ "DDB",
1976
+ "ASC",
1977
+ "DCI",
1978
+ "HEX",
1979
+ "PUT",
1980
+ "USE",
1981
+ "OBJ",
1982
+ "LST",
1983
+ "DO",
1984
+ "ELSE",
1985
+ "FIN",
1986
+ "LUP",
1987
+ "--^",
1988
+ "REL",
1989
+ "TYP",
1990
+ "SAV",
1991
+ "DSK",
1992
+ "CHN",
1993
+ "ENT",
1994
+ "EXT",
1995
+ "DUM",
1996
+ "DEND",
1997
+ "ERR",
1998
+ "CYC",
1999
+ "DAT",
2000
+ "EXP",
2001
+ "PAU",
2002
+ "SW",
2003
+ "USR",
2004
+ "XC",
2005
+ "MX",
2006
+ "TR",
2007
+ "KBD",
2008
+ "PMC",
2009
+ "PAG",
2010
+ "TTL",
2011
+ "SKP",
2012
+ "CHK",
2013
+ "IF",
2014
+ "ELUP",
2015
+ "END",
2016
+ "MAC",
2017
+ "EOM",
2018
+ "<<<",
2019
+ "ADR",
2020
+ "ADRL",
2021
+ "LNK",
2022
+ "STR",
2023
+ "STRL",
2024
+ "REV",
2025
+ ]);
2026
+
2027
+ return directives.has(upper);
2028
+ }
2029
+
2030
+ /**
2031
+ * Validate all lines
2032
+ */
2033
+ validateAllLines() {
2034
+ this.syntaxErrors.clear();
2035
+ const lines = this.textarea.value.split("\n");
2036
+
2037
+ // First pass: collect all symbol definitions to detect duplicates
2038
+ const symbolDefs = new Map(); // symbol name -> first line number
2039
+
2040
+ for (let i = 0; i < lines.length; i++) {
2041
+ const lineNumber = i + 1;
2042
+ const label = this.extractLabel(lines[i]);
2043
+ if (label) {
2044
+ const upperLabel = label.toUpperCase();
2045
+ if (symbolDefs.has(upperLabel)) {
2046
+ const firstLine = symbolDefs.get(upperLabel);
2047
+ this.syntaxErrors.set(
2048
+ lineNumber,
2049
+ `Duplicate symbol: ${label} (first defined on line ${firstLine})`,
2050
+ );
2051
+ } else {
2052
+ symbolDefs.set(upperLabel, lineNumber);
2053
+ }
2054
+ }
2055
+ }
2056
+
2057
+ // Second pass: validate each line for other syntax errors
2058
+ for (let i = 1; i <= lines.length; i++) {
2059
+ // Skip lines that already have duplicate symbol errors
2060
+ if (!this.syntaxErrors.has(i)) {
2061
+ this.validateLine(i);
2062
+ }
2063
+ }
2064
+ }
2065
+
2066
+ /**
2067
+ * Extract label from a line (if present)
2068
+ * Returns null if no label
2069
+ */
2070
+ extractLabel(line) {
2071
+ // No label if line starts with whitespace or is empty
2072
+ if (!line || line[0] === " " || line[0] === "\t") {
2073
+ return null;
2074
+ }
2075
+
2076
+ // Full-line comments have no label
2077
+ const trimmed = line.trim();
2078
+ if (trimmed.startsWith(";") || trimmed.startsWith("*")) {
2079
+ return null;
2080
+ }
2081
+
2082
+ // Extract first token (the label)
2083
+ let label = "";
2084
+ for (let i = 0; i < line.length; i++) {
2085
+ const ch = line[i];
2086
+ if (ch === " " || ch === "\t") {
2087
+ break;
2088
+ }
2089
+ label += ch;
2090
+ }
2091
+
2092
+ return label || null;
2093
+ }
2094
+
2095
+ updateHighlighting() {
2096
+ const text = this.textarea.value;
2097
+ const highlighted = highlightMerlinSourceInline(text);
2098
+
2099
+ // Just render the syntax highlighting - errors are shown via overlay
2100
+ this.highlight.innerHTML = highlighted + "\n";
2101
+
2102
+ // Update error highlights and messages overlay
2103
+ this.updateErrorsOverlay();
2104
+ }
2105
+
2106
+ updateErrorsOverlay() {
2107
+ if (!this.errorsOverlay) return;
2108
+
2109
+ // Combine assembler errors and syntax errors
2110
+ const allErrors = new Map();
2111
+ for (const [lineNum, msg] of this.syntaxErrors) {
2112
+ allErrors.set(lineNum, msg);
2113
+ }
2114
+ // Assembler errors override syntax errors
2115
+ for (const [lineNum, msg] of this.errors) {
2116
+ allErrors.set(lineNum, msg);
2117
+ }
2118
+
2119
+ if (allErrors.size === 0) {
2120
+ this.errorsOverlay.innerHTML = "";
2121
+ return;
2122
+ }
2123
+
2124
+ const style = getComputedStyle(this.textarea);
2125
+ const lineHeight =
2126
+ parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.4;
2127
+ const paddingTop = parseFloat(style.paddingTop) || 8;
2128
+
2129
+ let html = "";
2130
+ for (const [lineNum, msg] of allErrors) {
2131
+ // Position at the top of the error line
2132
+ const top = paddingTop + (lineNum - 1) * lineHeight;
2133
+ // Center of the line for the message
2134
+ const centerY = top + lineHeight / 2;
2135
+ // Error highlight bar (background)
2136
+ html += `<div class="asm-error-highlight" style="top: ${top}px; height: ${lineHeight}px"></div>`;
2137
+ // Error message (right side, vertically centered)
2138
+ html += `<div class="asm-error-msg" style="top: ${centerY}px">${this.escapeHtml(msg)}</div>`;
2139
+ }
2140
+
2141
+ this.errorsOverlay.innerHTML = html;
2142
+ }
2143
+
2144
+ doAssemble() {
2145
+ // Format all lines before assembling
2146
+ this.formatAllLines();
2147
+
2148
+ const text = this.textarea.value;
2149
+ if (!text.trim()) {
2150
+ this.setStatus("Nothing to assemble", false);
2151
+ return;
2152
+ }
2153
+
2154
+ // Check if source has ORG directive before any code
2155
+ const hasOrg = text.match(/^\s*ORG\b/im);
2156
+ if (!hasOrg) {
2157
+ this.setStatus("ORG directive required before code", false);
2158
+ return;
2159
+ }
2160
+
2161
+ // Clear previous errors
2162
+ this.errors.clear();
2163
+ this.syntaxErrors.clear();
2164
+
2165
+ // Validate all lines for syntax errors before assembly
2166
+ this.validateAllLines();
2167
+
2168
+ // If there are syntax errors, don't proceed with assembly
2169
+ if (this.syntaxErrors.size > 0) {
2170
+ const count = this.syntaxErrors.size;
2171
+ this.setStatus(`${count} syntax error${count !== 1 ? "s" : ""}`, false);
2172
+ this.loadBtn.disabled = true;
2173
+ this.debugBtn.disabled = true;
2174
+ this.clearOutputPanels();
2175
+ this.updateHighlighting();
2176
+ this.updateCyclesGutter();
2177
+ return;
2178
+ }
2179
+
2180
+ // Allocate source string in WASM heap
2181
+ const wasm = this.wasmModule;
2182
+ const sourceLen = text.length + 1;
2183
+ const sourcePtr = wasm._malloc(sourceLen);
2184
+ wasm.stringToUTF8(text, sourcePtr, sourceLen);
2185
+
2186
+ const success = wasm._assembleSource(sourcePtr);
2187
+ wasm._free(sourcePtr);
2188
+
2189
+ if (success) {
2190
+ const size = wasm._getAsmOutputSize();
2191
+ const origin = wasm._getAsmOrigin();
2192
+ this.lastAssembledSize = size;
2193
+ this.lastOrigin = origin;
2194
+ this.setStatus(
2195
+ `OK: ${size} bytes at $${origin.toString(16).toUpperCase().padStart(4, "0")}`,
2196
+ true,
2197
+ );
2198
+ this.loadBtn.disabled = !this.isRunningCallback();
2199
+ this.debugBtn.disabled = !this.isRunningCallback();
2200
+
2201
+ // Store symbols for byte encoding
2202
+ this.symbols.clear();
2203
+ const symbolCount = wasm._getAsmSymbolCount();
2204
+ for (let i = 0; i < symbolCount; i++) {
2205
+ const namePtr = wasm._getAsmSymbolName(i);
2206
+ const name = wasm.UTF8ToString(namePtr);
2207
+ const value = wasm._getAsmSymbolValue(i);
2208
+ this.symbols.set(name.toUpperCase(), value);
2209
+ }
2210
+
2211
+ // Export symbols to CPU debugger label manager
2212
+ if (this.cpuDebugger) {
2213
+ const lm = this.cpuDebugger.labelManager;
2214
+ lm.clearImportedBySource("assembler");
2215
+ for (const [name, value] of this.symbols) {
2216
+ if (value >= 0 && value <= 0xffff) {
2217
+ lm.importedLabels.set(value, { name, source: "assembler" });
2218
+ }
2219
+ }
2220
+ lm._notify();
2221
+ }
2222
+
2223
+ // Re-encode all lines with resolved symbols
2224
+ this.encodeAllLineBytes();
2225
+
2226
+ this.updateSymbolTable(wasm);
2227
+ this.updateHexOutput(wasm, origin, size);
2228
+ } else {
2229
+ const errorCount = wasm._getAsmErrorCount();
2230
+ this.setStatus(
2231
+ `${errorCount} error${errorCount !== 1 ? "s" : ""}`,
2232
+ false,
2233
+ );
2234
+ this.loadBtn.disabled = true;
2235
+ this.debugBtn.disabled = true;
2236
+
2237
+ // Collect errors
2238
+ for (let i = 0; i < errorCount; i++) {
2239
+ const line = wasm._getAsmErrorLine(i);
2240
+ const msgPtr = wasm._getAsmErrorMessage(i);
2241
+ const msg = wasm.UTF8ToString(msgPtr);
2242
+
2243
+ if (line >= 1) {
2244
+ this.errors.set(line, msg);
2245
+ // Clear bytes for error lines
2246
+ this.lineBytes.delete(line);
2247
+ }
2248
+ }
2249
+
2250
+ this.clearOutputPanels();
2251
+ }
2252
+
2253
+ // Re-render highlighting with error markers
2254
+ this.updateHighlighting();
2255
+ this.updateCyclesGutter();
2256
+ }
2257
+
2258
+ updateSymbolTable(wasm) {
2259
+ const count = wasm._getAsmSymbolCount();
2260
+
2261
+ // Update count badge
2262
+ if (this.symbolsCount) {
2263
+ this.symbolsCount.textContent = count > 0 ? count : "";
2264
+ }
2265
+
2266
+ if (count === 0) {
2267
+ this.symbolsContent.innerHTML = `
2268
+ <div class="asm-panel-empty">
2269
+ <div class="asm-empty-icon">{ }</div>
2270
+ <div class="asm-empty-text">No symbols defined</div>
2271
+ </div>`;
2272
+ return;
2273
+ }
2274
+
2275
+ // Separate symbols into labels and equates
2276
+ const labels = [];
2277
+ const equates = [];
2278
+
2279
+ for (let i = 0; i < count; i++) {
2280
+ const namePtr = wasm._getAsmSymbolName(i);
2281
+ const name = wasm.UTF8ToString(namePtr);
2282
+ const value = wasm._getAsmSymbolValue(i);
2283
+ const hex =
2284
+ "$" + (value & 0xffff).toString(16).toUpperCase().padStart(4, "0");
2285
+ const isLocal = name.startsWith(":") || name.startsWith("]");
2286
+ const item = { name, hex, isLocal };
2287
+
2288
+ // Heuristic: values in ROM range ($F800+) or under $0100 are likely equates
2289
+ if (value >= 0xf800 || value < 0x0100) {
2290
+ equates.push(item);
2291
+ } else {
2292
+ labels.push(item);
2293
+ }
2294
+ }
2295
+
2296
+ let html = '<div class="asm-symbol-list">';
2297
+
2298
+ if (labels.length > 0) {
2299
+ html += '<div class="asm-symbol-group">';
2300
+ html += '<div class="asm-symbol-group-header">Labels</div>';
2301
+ for (const item of labels) {
2302
+ const cls = item.isLocal ? "asm-sym-local" : "asm-sym-global";
2303
+ html += `<div class="asm-symbol-row">
2304
+ <span class="asm-sym-name ${cls}">${this.escapeHtml(item.name)}</span>
2305
+ <span class="asm-sym-value">${item.hex}</span>
2306
+ </div>`;
2307
+ }
2308
+ html += "</div>";
2309
+ }
2310
+
2311
+ if (equates.length > 0) {
2312
+ html += '<div class="asm-symbol-group">';
2313
+ html += '<div class="asm-symbol-group-header">Equates</div>';
2314
+ for (const item of equates) {
2315
+ html += `<div class="asm-symbol-row">
2316
+ <span class="asm-sym-name asm-sym-equ">${this.escapeHtml(item.name)}</span>
2317
+ <span class="asm-sym-value">${item.hex}</span>
2318
+ </div>`;
2319
+ }
2320
+ html += "</div>";
2321
+ }
2322
+
2323
+ html += "</div>";
2324
+ this.symbolsContent.innerHTML = html;
2325
+ }
2326
+
2327
+ updateHexOutput(wasm, origin, size) {
2328
+ // Update count badge
2329
+ if (this.hexCount) {
2330
+ this.hexCount.textContent = size > 0 ? `${size} bytes` : "";
2331
+ }
2332
+
2333
+ if (size === 0) {
2334
+ this.hexContent.innerHTML = `
2335
+ <div class="asm-panel-empty">
2336
+ <div class="asm-empty-icon">[ ]</div>
2337
+ <div class="asm-empty-text">No output</div>
2338
+ </div>`;
2339
+ return;
2340
+ }
2341
+
2342
+ const bufPtr = wasm._getAsmOutputBuffer();
2343
+ const data = new Uint8Array(wasm.HEAPU8.buffer, bufPtr, size);
2344
+
2345
+ // Header showing range
2346
+ const endAddr = origin + size - 1;
2347
+ const rangeStr = `$${origin.toString(16).toUpperCase().padStart(4, "0")} - $${(endAddr & 0xffff).toString(16).toUpperCase().padStart(4, "0")}`;
2348
+
2349
+ let html = `<div class="asm-hex-header">
2350
+ <span class="asm-hex-range">${rangeStr}</span>
2351
+ <span class="asm-hex-size">${size} bytes</span>
2352
+ </div>`;
2353
+
2354
+ html += '<div class="asm-hex-dump">';
2355
+ const bytesPerRow = 8; // Use 8 bytes for cleaner display
2356
+
2357
+ for (let offset = 0; offset < size; offset += bytesPerRow) {
2358
+ const addr = origin + offset;
2359
+ const addrStr =
2360
+ "$" + (addr & 0xffff).toString(16).toUpperCase().padStart(4, "0");
2361
+
2362
+ let hexPart = "";
2363
+ let asciiPart = "";
2364
+
2365
+ for (let i = 0; i < bytesPerRow; i++) {
2366
+ if (offset + i < size) {
2367
+ const byte = data[offset + i];
2368
+ hexPart += byte.toString(16).toUpperCase().padStart(2, "0") + " ";
2369
+ asciiPart +=
2370
+ byte >= 0x20 && byte <= 0x7e ? String.fromCharCode(byte) : "·";
2371
+ } else {
2372
+ hexPart += " ";
2373
+ asciiPart += " ";
2374
+ }
2375
+ }
2376
+
2377
+ html +=
2378
+ `<div class="asm-hex-row">` +
2379
+ `<span class="asm-hex-addr">${addrStr}</span>` +
2380
+ `<span class="asm-hex-sep">│</span>` +
2381
+ `<span class="asm-hex-bytes">${hexPart}</span>` +
2382
+ `<span class="asm-hex-sep">│</span>` +
2383
+ `<span class="asm-hex-ascii">${this.escapeHtml(asciiPart)}</span>` +
2384
+ `</div>`;
2385
+ }
2386
+
2387
+ html += "</div>";
2388
+ this.hexContent.innerHTML = html;
2389
+ }
2390
+
2391
+ clearOutputPanels() {
2392
+ // Clear count badges
2393
+ if (this.symbolsCount) this.symbolsCount.textContent = "";
2394
+ if (this.hexCount) this.hexCount.textContent = "";
2395
+
2396
+ this.symbolsContent.innerHTML = `
2397
+ <div class="asm-panel-empty asm-panel-error">
2398
+ <div class="asm-empty-icon">⚠</div>
2399
+ <div class="asm-empty-text">Fix errors to see symbols</div>
2400
+ </div>`;
2401
+ this.hexContent.innerHTML = `
2402
+ <div class="asm-panel-empty asm-panel-error">
2403
+ <div class="asm-empty-icon">⚠</div>
2404
+ <div class="asm-empty-text">Fix errors to see output</div>
2405
+ </div>`;
2406
+ }
2407
+
2408
+ escapeHtml(text) {
2409
+ const div = document.createElement("div");
2410
+ div.textContent = text;
2411
+ return div.innerHTML;
2412
+ }
2413
+
2414
+ loadExample() {
2415
+ const example = `; Hello World - prints a message and returns to the monitor
2416
+ ;
2417
+ ; Assemble, Load, then type CALL 2048 from within BASIC.
2418
+
2419
+ ORG $0800
2420
+
2421
+ COUT EQU $FDED ;ROM character output routine
2422
+ CROUT EQU $FD8E ;ROM carriage return
2423
+
2424
+ START LDX #0 ;Start at first character
2425
+ LOOP LDA MSG,X ;Load next character
2426
+ BEQ DONE ;Zero byte = end of string
2427
+ JSR COUT ;Print character
2428
+ INX ;Next character
2429
+ BNE LOOP ;Continue (max 256 chars)
2430
+ DONE JSR CROUT ;Print carriage return
2431
+ RTS ;Return to monitor
2432
+
2433
+ MSG ASC "HELLO FROM THE APPLE //E EMULATOR!"
2434
+ DFB $00 ;Null terminator`;
2435
+
2436
+ this.textarea.value = example;
2437
+ this.textarea.dispatchEvent(new Event("input", { bubbles: true }));
2438
+ this.doClear();
2439
+ this.currentFileName = null;
2440
+ this.validateAllLines();
2441
+ this.encodeAllLineBytes();
2442
+ this.updateGutter();
2443
+ this.updateCursorPosition();
2444
+ }
2445
+
2446
+ doClear() {
2447
+ this.symbols.clear();
2448
+ this.lineBytes.clear();
2449
+ this.linePCs = new Map();
2450
+ this.errors.clear();
2451
+ this.syntaxErrors.clear();
2452
+ this.loadBtn.disabled = true;
2453
+ this.debugBtn.disabled = true;
2454
+ this.setStatus("", false);
2455
+
2456
+ // Clear output panels to empty state
2457
+ if (this.symbolsCount) this.symbolsCount.textContent = "";
2458
+ if (this.hexCount) this.hexCount.textContent = "";
2459
+ this.symbolsContent.innerHTML = `
2460
+ <div class="asm-panel-empty">
2461
+ <div class="asm-empty-text">Assemble to see symbols</div>
2462
+ </div>`;
2463
+ this.hexContent.innerHTML = `
2464
+ <div class="asm-panel-empty">
2465
+ <div class="asm-empty-text">Assemble to see output</div>
2466
+ </div>`;
2467
+
2468
+ this.updateGutter();
2469
+ this.updateHighlighting();
2470
+ }
2471
+
2472
+ doLoad() {
2473
+ this.wasmModule._loadAsmIntoMemory();
2474
+ this.showLoadedFeedback();
2475
+ }
2476
+
2477
+ showLoadedFeedback() {
2478
+ const originalHtml = this.loadBtn.innerHTML;
2479
+ this.loadBtn.innerHTML = `<span class="asm-btn-icon">✓</span> Loaded!`;
2480
+ this.loadBtn.classList.add("asm-btn-success");
2481
+
2482
+ setTimeout(() => {
2483
+ this.loadBtn.innerHTML = originalHtml;
2484
+ this.loadBtn.classList.remove("asm-btn-success");
2485
+ }, 1500);
2486
+ }
2487
+
2488
+ /**
2489
+ * Create a new empty file
2490
+ */
2491
+ async newFile() {
2492
+ if (this.textarea.value.trim()) {
2493
+ const confirmed = await showConfirm(
2494
+ "Clear current source and start new file?",
2495
+ );
2496
+ if (!confirmed) return;
2497
+ }
2498
+ this.textarea.value = "";
2499
+ this.currentFileName = null;
2500
+ this._fileHandle = null;
2501
+ this.updateTitle("Assembler");
2502
+ this.updateHighlighting();
2503
+ this.updateGutter();
2504
+ this.errors.clear();
2505
+ this.syntaxErrors.clear();
2506
+ this.clearOutputPanels();
2507
+ this.setStatus("", true);
2508
+ }
2509
+
2510
+ /**
2511
+ * Open a file from the local filesystem using the host file picker
2512
+ */
2513
+ async openFile() {
2514
+ try {
2515
+ const [handle] = await window.showOpenFilePicker({
2516
+ types: [
2517
+ {
2518
+ description: "Assembly source files",
2519
+ accept: { "text/plain": [".s", ".asm", ".a65", ".txt"] },
2520
+ },
2521
+ ],
2522
+ multiple: false,
2523
+ });
2524
+ const file = await handle.getFile();
2525
+ const text = await file.text();
2526
+ this.textarea.value = text;
2527
+ this.currentFileName = file.name;
2528
+ this._fileHandle = handle;
2529
+ this.updateTitle(`Assembler - ${file.name}`);
2530
+ this.updateHighlighting();
2531
+ this.validateAllLines();
2532
+ this.encodeAllLineBytes();
2533
+ this.updateGutter();
2534
+ this.setStatus(`Opened: ${file.name}`, true);
2535
+ } catch (err) {
2536
+ if (err.name !== "AbortError") {
2537
+ this.setStatus("Failed to open file", false);
2538
+ }
2539
+ }
2540
+ }
2541
+
2542
+ /**
2543
+ * Save the current source to a file using the host save dialog
2544
+ */
2545
+ async saveFile() {
2546
+ const content = this.textarea.value;
2547
+ if (!content.trim()) {
2548
+ this.setStatus("Nothing to save", false);
2549
+ return;
2550
+ }
2551
+
2552
+ try {
2553
+ // Reuse existing handle if we have one, otherwise prompt
2554
+ if (!this._fileHandle) {
2555
+ this._fileHandle = await window.showSaveFilePicker({
2556
+ suggestedName: this.currentFileName || "untitled.s",
2557
+ types: [
2558
+ {
2559
+ description: "Assembly source files",
2560
+ accept: { "text/plain": [".s", ".asm", ".a65", ".txt"] },
2561
+ },
2562
+ ],
2563
+ });
2564
+ }
2565
+
2566
+ const writable = await this._fileHandle.createWritable();
2567
+ await writable.write(content);
2568
+ await writable.close();
2569
+
2570
+ const filename = this._fileHandle.name;
2571
+ this.currentFileName = filename;
2572
+ this.updateTitle(`Assembler - ${filename}`);
2573
+ this.setStatus(`Saved: ${filename}`, true);
2574
+ } catch (err) {
2575
+ if (err.name !== "AbortError") {
2576
+ this.setStatus("Failed to save file", false);
2577
+ }
2578
+ }
2579
+ }
2580
+
2581
+ /**
2582
+ * Update the window title
2583
+ */
2584
+ updateTitle(title) {
2585
+ const titleEl = this.windowElement?.querySelector(".window-title");
2586
+ if (titleEl) {
2587
+ titleEl.textContent = title;
2588
+ }
2589
+ }
2590
+
2591
+ setStatus(text, ok) {
2592
+ this.statusSpan.textContent = text;
2593
+ this.statusSpan.className =
2594
+ "asm-status" + (ok ? " asm-status-ok" : " asm-status-error");
2595
+ }
2596
+
2597
+ goToLine(lineNumber) {
2598
+ if (!this.textarea) return;
2599
+ const lines = this.textarea.value.split("\n");
2600
+ let pos = 0;
2601
+ for (let i = 0; i < lineNumber - 1 && i < lines.length; i++) {
2602
+ pos += lines[i].length + 1;
2603
+ }
2604
+ this.textarea.focus();
2605
+ this.textarea.setSelectionRange(pos, pos);
2606
+ this.updateCurrentLineHighlight();
2607
+
2608
+ // Scroll line into view
2609
+ const style = getComputedStyle(this.textarea);
2610
+ const lineHeight =
2611
+ parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.4;
2612
+ const targetScroll =
2613
+ (lineNumber - 1) * lineHeight - this.textarea.clientHeight / 2;
2614
+ this.textarea.scrollTop = Math.max(0, targetScroll);
2615
+ }
2616
+
2617
+ /**
2618
+ * Toggle a breakpoint for the given line number
2619
+ */
2620
+ toggleBreakpoint(lineNumber) {
2621
+ if (!this.bpManager) return;
2622
+
2623
+ // Get the PC address for this line
2624
+ const address = this.linePCs.get(lineNumber);
2625
+ if (address === undefined) {
2626
+ // No instruction on this line (empty, comment, or directive)
2627
+ return;
2628
+ }
2629
+
2630
+ // Check if line has an actual instruction (not just a label or directive)
2631
+ const lines = this.textarea.value.split("\n");
2632
+ if (lineNumber < 1 || lineNumber > lines.length) return;
2633
+
2634
+ const parsed = this.parseLine(lines[lineNumber - 1]);
2635
+ if (!parsed || !parsed.opcode) return;
2636
+
2637
+ // Skip directives - they don't generate code
2638
+ const opcodes = this.getOpcodeTable();
2639
+ if (!opcodes[parsed.opcode.toUpperCase()]) return;
2640
+
2641
+ // Toggle the breakpoint
2642
+ this.bpManager.toggle(address);
2643
+
2644
+ // Update our local tracking
2645
+ if (this.bpManager.has(address)) {
2646
+ this.lineBreakpoints.set(lineNumber, address);
2647
+ } else {
2648
+ this.lineBreakpoints.delete(lineNumber);
2649
+ }
2650
+
2651
+ this.updateGutter();
2652
+ }
2653
+
2654
+ /**
2655
+ * Sync breakpoint state from the manager (called when breakpoints change externally)
2656
+ */
2657
+ syncBreakpointsFromManager() {
2658
+ if (!this.bpManager) return;
2659
+
2660
+ // Clear our local tracking and rebuild from manager state
2661
+ this.lineBreakpoints.clear();
2662
+
2663
+ // Check each line's PC against the manager's breakpoints
2664
+ for (const [lineNumber, address] of this.linePCs) {
2665
+ if (this.bpManager.has(address)) {
2666
+ this.lineBreakpoints.set(lineNumber, address);
2667
+ }
2668
+ }
2669
+
2670
+ this.updateGutter();
2671
+ }
2672
+
2673
+ // ============================================================================
2674
+ // ROM Routines Panel
2675
+ // ============================================================================
2676
+
2677
+ initRomPanel() {
2678
+ // Populate category dropdown
2679
+ ROM_CATEGORIES.forEach((cat) => {
2680
+ const opt = document.createElement("option");
2681
+ opt.value = cat;
2682
+ opt.textContent = cat === "All" ? "All Categories" : cat;
2683
+ this.romCategory.appendChild(opt);
2684
+ });
2685
+
2686
+ // ROM button click
2687
+ this.romBtn.addEventListener("click", () => this.toggleRomPanel());
2688
+
2689
+ // Close button
2690
+ this.romPanel
2691
+ .querySelector(".asm-rom-close")
2692
+ .addEventListener("click", () => {
2693
+ this.hideRomPanel();
2694
+ });
2695
+
2696
+ // Search input
2697
+ this.romSearch.addEventListener("input", () => this.filterRomRoutines());
2698
+
2699
+ // Category filter
2700
+ this.romCategory.addEventListener("change", () => this.filterRomRoutines());
2701
+
2702
+ // Back button in detail view
2703
+ this.romPanel
2704
+ .querySelector(".asm-rom-back")
2705
+ .addEventListener("click", () => {
2706
+ this.romDetail.classList.add("hidden");
2707
+ this.romList.classList.remove("hidden");
2708
+ });
2709
+
2710
+ // Insert buttons
2711
+ this.romPanel
2712
+ .querySelector(".asm-rom-insert-equ")
2713
+ .addEventListener("click", () => {
2714
+ if (this.selectedRoutine) this.insertRoutineEqu(this.selectedRoutine);
2715
+ });
2716
+ this.romPanel
2717
+ .querySelector(".asm-rom-insert-jsr")
2718
+ .addEventListener("click", () => {
2719
+ if (this.selectedRoutine) this.insertRoutineJsr(this.selectedRoutine);
2720
+ });
2721
+
2722
+ // Escape key to close panel
2723
+ this.romPanel.addEventListener("keydown", (e) => {
2724
+ if (e.key === "Escape") {
2725
+ this.hideRomPanel();
2726
+ }
2727
+ });
2728
+
2729
+ // Populate initial list
2730
+ this.renderRomList(ROM_ROUTINES);
2731
+ }
2732
+
2733
+ toggleRomPanel() {
2734
+ if (this.romPanel.classList.contains("hidden")) {
2735
+ this.showRomPanel();
2736
+ } else {
2737
+ this.hideRomPanel();
2738
+ }
2739
+ }
2740
+
2741
+ showRomPanel() {
2742
+ this.romPanel.classList.remove("hidden");
2743
+ this.romSearch.focus();
2744
+ this.romSearch.select();
2745
+ }
2746
+
2747
+ hideRomPanel() {
2748
+ this.romPanel.classList.add("hidden");
2749
+ this.romDetail.classList.add("hidden");
2750
+ this.romList.classList.remove("hidden");
2751
+ this.textarea.focus();
2752
+ }
2753
+
2754
+ filterRomRoutines() {
2755
+ const query = this.romSearch.value.trim();
2756
+ const category = this.romCategory.value;
2757
+
2758
+ let routines;
2759
+ if (query) {
2760
+ routines = searchRoutines(query);
2761
+ if (category !== "All") {
2762
+ routines = routines.filter((r) => r.category === category);
2763
+ }
2764
+ } else {
2765
+ routines = getRoutinesByCategory(category);
2766
+ }
2767
+
2768
+ this.renderRomList(routines);
2769
+ }
2770
+
2771
+ renderRomList(routines) {
2772
+ if (routines.length === 0) {
2773
+ this.romList.innerHTML = `
2774
+ <div class="asm-rom-empty">
2775
+ <div class="asm-rom-empty-icon">🔍</div>
2776
+ <div class="asm-rom-empty-text">No routines found</div>
2777
+ </div>`;
2778
+ return;
2779
+ }
2780
+
2781
+ const html = routines
2782
+ .map(
2783
+ (r) => `
2784
+ <div class="asm-rom-item" data-name="${r.name}">
2785
+ <div class="asm-rom-item-header">
2786
+ <span class="asm-rom-item-name">${r.name}</span>
2787
+ <span class="asm-rom-item-addr">$${r.address.toString(16).toUpperCase().padStart(4, "0")}</span>
2788
+ </div>
2789
+ <div class="asm-rom-item-desc">${r.description}</div>
2790
+ <div class="asm-rom-item-cat">${r.category}</div>
2791
+ </div>
2792
+ `,
2793
+ )
2794
+ .join("");
2795
+
2796
+ this.romList.innerHTML = html;
2797
+
2798
+ // Add click handlers
2799
+ this.romList.querySelectorAll(".asm-rom-item").forEach((item) => {
2800
+ item.addEventListener("click", () => {
2801
+ const name = item.dataset.name;
2802
+ const routine = ROM_ROUTINES.find((r) => r.name === name);
2803
+ if (routine) this.showRoutineDetail(routine);
2804
+ });
2805
+ });
2806
+ }
2807
+
2808
+ showRoutineDetail(routine) {
2809
+ this.selectedRoutine = routine;
2810
+ this.romDetailName.textContent = `${routine.name} ($${routine.address.toString(16).toUpperCase().padStart(4, "0")})`;
2811
+
2812
+ let html = `
2813
+ <div class="asm-rom-section">
2814
+ <div class="asm-rom-section-title">Description</div>
2815
+ <div class="asm-rom-section-content">${routine.description}</div>
2816
+ </div>
2817
+ `;
2818
+
2819
+ if (routine.inputs && routine.inputs.length > 0) {
2820
+ html += `
2821
+ <div class="asm-rom-section">
2822
+ <div class="asm-rom-section-title">Inputs</div>
2823
+ <div class="asm-rom-section-content">
2824
+ ${routine.inputs
2825
+ .map(
2826
+ (i) => `
2827
+ <div class="asm-rom-param">
2828
+ <span class="asm-rom-param-reg">${i.register}</span>
2829
+ <span class="asm-rom-param-desc">${i.description}</span>
2830
+ </div>
2831
+ `,
2832
+ )
2833
+ .join("")}
2834
+ </div>
2835
+ </div>
2836
+ `;
2837
+ }
2838
+
2839
+ if (routine.outputs && routine.outputs.length > 0) {
2840
+ html += `
2841
+ <div class="asm-rom-section">
2842
+ <div class="asm-rom-section-title">Outputs</div>
2843
+ <div class="asm-rom-section-content">
2844
+ ${routine.outputs
2845
+ .map(
2846
+ (o) => `
2847
+ <div class="asm-rom-param">
2848
+ <span class="asm-rom-param-reg">${o.register}</span>
2849
+ <span class="asm-rom-param-desc">${o.description}</span>
2850
+ </div>
2851
+ `,
2852
+ )
2853
+ .join("")}
2854
+ </div>
2855
+ </div>
2856
+ `;
2857
+ }
2858
+
2859
+ if (routine.preserves && routine.preserves.length > 0) {
2860
+ html += `
2861
+ <div class="asm-rom-section asm-rom-section-inline">
2862
+ <span class="asm-rom-section-label">Preserves:</span>
2863
+ <span class="asm-rom-regs">${routine.preserves.join(", ")}</span>
2864
+ </div>
2865
+ `;
2866
+ }
2867
+
2868
+ if (routine.clobbers && routine.clobbers.length > 0) {
2869
+ html += `
2870
+ <div class="asm-rom-section asm-rom-section-inline">
2871
+ <span class="asm-rom-section-label">Clobbers:</span>
2872
+ <span class="asm-rom-regs asm-rom-regs-warn">${routine.clobbers.join(", ")}</span>
2873
+ </div>
2874
+ `;
2875
+ }
2876
+
2877
+ if (routine.notes) {
2878
+ html += `
2879
+ <div class="asm-rom-section">
2880
+ <div class="asm-rom-section-title">Notes</div>
2881
+ <div class="asm-rom-section-content asm-rom-notes">${routine.notes}</div>
2882
+ </div>
2883
+ `;
2884
+ }
2885
+
2886
+ if (routine.example) {
2887
+ html += `
2888
+ <div class="asm-rom-section">
2889
+ <div class="asm-rom-section-title">Example</div>
2890
+ <pre class="asm-rom-example">${this.escapeHtml(routine.example)}</pre>
2891
+ </div>
2892
+ `;
2893
+ }
2894
+
2895
+ this.romDetailContent.innerHTML = html;
2896
+ this.romList.classList.add("hidden");
2897
+ this.romDetail.classList.remove("hidden");
2898
+ }
2899
+
2900
+ insertRoutineEqu(routine) {
2901
+ const equ = `${routine.name.padEnd(8)} EQU $${routine.address.toString(16).toUpperCase().padStart(4, "0")}`;
2902
+ this.insertAtCursor(equ + "\n");
2903
+ this.hideRomPanel();
2904
+ }
2905
+
2906
+ insertRoutineJsr(routine) {
2907
+ // Check if EQU already exists
2908
+ const hasEqu = this.textarea.value
2909
+ .toUpperCase()
2910
+ .includes(`${routine.name.toUpperCase()} `);
2911
+
2912
+ this.textarea.focus();
2913
+
2914
+ if (!hasEqu) {
2915
+ // Need to insert EQU at top (after header comments) AND JSR at cursor
2916
+ // Save current cursor position
2917
+ const savedCursor = this.textarea.selectionStart;
2918
+ const lines = this.textarea.value.split("\n");
2919
+
2920
+ // Find insert point for EQU (after header comments)
2921
+ let insertIdx = 0;
2922
+ let charPos = 0;
2923
+ for (let i = 0; i < lines.length; i++) {
2924
+ const trimmed = lines[i].trim();
2925
+ if (trimmed && !trimmed.startsWith("*") && !trimmed.startsWith(";")) {
2926
+ insertIdx = i;
2927
+ break;
2928
+ }
2929
+ charPos += lines[i].length + 1; // +1 for newline
2930
+ insertIdx = i + 1;
2931
+ }
2932
+
2933
+ const equ = `${routine.name.padEnd(8)} EQU $${routine.address.toString(16).toUpperCase().padStart(4, "0")}\n`;
2934
+
2935
+ // Move cursor to EQU insert position and insert
2936
+ this.textarea.selectionStart = this.textarea.selectionEnd = charPos;
2937
+ document.execCommand("insertText", false, equ);
2938
+
2939
+ // Move cursor back to original position (adjusted for inserted EQU line)
2940
+ const newCursorPos = savedCursor + equ.length;
2941
+ this.textarea.selectionStart = this.textarea.selectionEnd = newCursorPos;
2942
+ }
2943
+
2944
+ // Insert JSR at cursor
2945
+ const jsr = ` JSR ${routine.name}`;
2946
+ this.insertAtCursor(jsr);
2947
+ this.hideRomPanel();
2948
+ }
2949
+
2950
+ insertAtCursor(text) {
2951
+ this.textarea.focus();
2952
+
2953
+ const start = this.textarea.selectionStart;
2954
+ const value = this.textarea.value;
2955
+
2956
+ // If not at start of line, add newline first
2957
+ let insertText = text;
2958
+ if (start > 0 && value[start - 1] !== "\n") {
2959
+ insertText = "\n" + text;
2960
+ }
2961
+
2962
+ // Use execCommand to preserve undo history
2963
+ // This inserts text at the current selection and is undoable with Cmd+Z
2964
+ document.execCommand("insertText", false, insertText);
2965
+
2966
+ // Update display
2967
+ this.updateHighlighting();
2968
+ this.updateGutter();
2969
+ }
2970
+
2971
+ update() {
2972
+ // No periodic update needed
2973
+ }
2974
+
2975
+ getState() {
2976
+ const baseState = super.getState();
2977
+ return {
2978
+ ...baseState,
2979
+ content: this.textarea ? this.textarea.value : "",
2980
+ };
2981
+ }
2982
+
2983
+ restoreState(state) {
2984
+ super.restoreState(state);
2985
+ if (state.content !== undefined && this.textarea) {
2986
+ this.textarea.value = state.content;
2987
+ this.updateHighlighting();
2988
+ this.validateAllLines();
2989
+ this.encodeAllLineBytes();
2990
+ this.updateGutter();
2991
+ }
2992
+ }
2993
+ }