jsbeeb 1.1.1
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.
- package/.editorconfig +15 -0
- package/.git-blame-ignore-revs +3 -0
- package/.github/copilot-instructions.md +94 -0
- package/.github/workflows/claude-issue-triage.yml +105 -0
- package/.github/workflows/claude.yml +63 -0
- package/.github/workflows/release-please.yml +75 -0
- package/.github/workflows/test-and-deploy.yml +86 -0
- package/.gitmodules +6 -0
- package/.husky/pre-commit +1 -0
- package/.idea/codeStyleSettings.xml +9 -0
- package/.idea/codeStyles/Project.xml +62 -0
- package/.idea/codeStyles/codeStyleConfig.xml +5 -0
- package/.idea/compiler.xml +22 -0
- package/.idea/copyright/profiles_settings.xml +3 -0
- package/.idea/encodings.xml +6 -0
- package/.idea/inspectionProfiles/Project_Default.xml +7 -0
- package/.idea/jsLibraryMappings.xml +6 -0
- package/.idea/jsLinters/jshint.xml +85 -0
- package/.idea/jsLinters/jslint.xml +15 -0
- package/.idea/jsbeeb.iml +11 -0
- package/.idea/misc.xml +6 -0
- package/.idea/modules.xml +8 -0
- package/.idea/prettier.xml +7 -0
- package/.idea/runConfigurations/Debug.xml +5 -0
- package/.idea/scopes/scope_settings.xml +5 -0
- package/.idea/vcs.xml +8 -0
- package/.prettierignore +4 -0
- package/.prettierrc.json +1 -0
- package/.release-please-manifest.json +3 -0
- package/.vscode/launch.json +14 -0
- package/.vscode/settings.json +6 -0
- package/CHANGELOG.md +32 -0
- package/CLAUDE.md +136 -0
- package/COPYING +674 -0
- package/Dockerfile +22 -0
- package/Makefile +30 -0
- package/README.md +259 -0
- package/docker/nginx-default.conf +10 -0
- package/docs/pal-comb-filter-research.md +129 -0
- package/docs/pal-simulation-design.md +368 -0
- package/eslint.config.js +35 -0
- package/index.html +954 -0
- package/jsconfig.json +10 -0
- package/package.json +102 -0
- package/public/discs/README.Irq-Timing +3 -0
- package/public/discs/README.bcdtest +5 -0
- package/public/discs/README.elite +6 -0
- package/public/discs/README.eng_test +3 -0
- package/public/discs/README.protection +7 -0
- package/public/favicon.ico +0 -0
- package/public/images/botbar.png +0 -0
- package/public/images/cub-monitor.png +0 -0
- package/public/images/jsbeeb-example.png +0 -0
- package/public/images/placeholder.png +0 -0
- package/public/images/red-off-16.png +0 -0
- package/public/images/red-on-16.png +0 -0
- package/public/images/sb/CD-left.jpg +0 -0
- package/public/images/sb/CD-right.jpg +0 -0
- package/public/images/sidebar.png +0 -0
- package/public/images/tv.png +0 -0
- package/public/images/yellow-off-16.png +0 -0
- package/public/images/yellow-on-16.png +0 -0
- package/public/jsbeeb-icon.png +0 -0
- package/public/robots.txt +3 -0
- package/public/roms/ADFS1-53.rom +0 -0
- package/public/roms/BASIC.ROM +0 -0
- package/public/roms/README +4 -0
- package/public/roms/a01/BASIC1.rom +0 -0
- package/public/roms/ample.rom +0 -0
- package/public/roms/ats-3.0.rom +0 -0
- package/public/roms/b/DFS-0.9.rom +0 -0
- package/public/roms/b/DFS-1.2.rom +0 -0
- package/public/roms/b1770/dfs1770.rom +0 -0
- package/public/roms/b1770/zADFS.ROM +0 -0
- package/public/roms/bp/dfs.rom +0 -0
- package/public/roms/bp/zADFS.ROM +0 -0
- package/public/roms/bpos.rom +0 -0
- package/public/roms/compact/adfs210.rom +0 -0
- package/public/roms/compact/basic48.rom +0 -0
- package/public/roms/compact/basic486.rom +0 -0
- package/public/roms/compact/os51.rom +0 -0
- package/public/roms/compact/utils.rom +0 -0
- package/public/roms/deos.rom +0 -0
- package/public/roms/master/anfs-4.25.rom +0 -0
- package/public/roms/master/mos.txt +68819 -0
- package/public/roms/master/mos3.20 +0 -0
- package/public/roms/os.rom +0 -0
- package/public/roms/os01.rom +0 -0
- package/public/roms/tube/6502Tube.rom +0 -0
- package/public/roms/tube/ARMeval_100.rom +0 -0
- package/public/roms/tube/BIOS.ROM +0 -0
- package/public/roms/tube/ReCo6502ROM_816 +0 -0
- package/public/roms/tube/Z80_120.rom +0 -0
- package/public/roms/us/USBASIC.rom +0 -0
- package/public/roms/us/USDNFS.rom +0 -0
- package/public/roms/usmos.rom +0 -0
- package/public/sounds/disc525/motor.wav +0 -0
- package/public/sounds/disc525/motoroff.wav +0 -0
- package/public/sounds/disc525/motoron.wav +0 -0
- package/public/sounds/disc525/seek.wav +0 -0
- package/public/sounds/disc525/seek2.wav +0 -0
- package/public/sounds/disc525/seek3.wav +0 -0
- package/public/sounds/disc525/step.wav +0 -0
- package/public/teletext/txt0.dat +0 -0
- package/public/teletext/txt1.dat +0 -0
- package/public/teletext/txt2.dat +0 -0
- package/public/teletext/txt3.dat +0 -0
- package/release-please-config.json +13 -0
- package/run-container.sh +92 -0
- package/src/6502.js +1347 -0
- package/src/6502.opcodes.js +1411 -0
- package/src/acia.js +261 -0
- package/src/adc.js +149 -0
- package/src/analogue-source.js +21 -0
- package/src/app/app.js +175 -0
- package/src/app/electron.js +20 -0
- package/src/app/preload.js +8 -0
- package/src/app-bench.js +33 -0
- package/src/basic/multiline-tetris +9 -0
- package/src/basic-tokenise.js +104 -0
- package/src/canvas.js +177 -0
- package/src/cmos.js +141 -0
- package/src/config.js +165 -0
- package/src/ddnoise.js +138 -0
- package/src/disc-drive.js +371 -0
- package/src/disc-hfe.js +396 -0
- package/src/disc.js +997 -0
- package/src/econet/L3FS.dat +0 -0
- package/src/econet/scsi.dat +0 -0
- package/src/econet.js +714 -0
- package/src/fake6502.js +32 -0
- package/src/fdc.js +248 -0
- package/src/filestore.js +666 -0
- package/src/gamepad-source.js +59 -0
- package/src/gamepads.js +268 -0
- package/src/google-drive.js +160 -0
- package/src/intel-fdc.js +1717 -0
- package/src/jsbeeb.css +363 -0
- package/src/keyboard.js +411 -0
- package/src/lib/README +1 -0
- package/src/lib/webgl-debug.js +911 -0
- package/src/main.js +1759 -0
- package/src/microphone-input.js +149 -0
- package/src/models.js +200 -0
- package/src/mouse-joystick-source.js +107 -0
- package/src/music5000-worklet.js +43 -0
- package/src/music5000.js +207 -0
- package/src/scheduler.js +148 -0
- package/src/serial.js +31 -0
- package/src/soundchip.js +314 -0
- package/src/sth.js +59 -0
- package/src/tapes.js +265 -0
- package/src/teletext.js +348 -0
- package/src/teletext_adaptor.js +172 -0
- package/src/teletext_data.js +1064 -0
- package/src/touchscreen.js +86 -0
- package/src/tube.js +349 -0
- package/src/url-params.js +256 -0
- package/src/utils.js +1090 -0
- package/src/via.js +702 -0
- package/src/video-filters/pal-composite.js +94 -0
- package/src/video-filters/passthrough-filter.js +70 -0
- package/src/video-filters/shaders/pal-composite.frag.glsl +147 -0
- package/src/video-filters/shaders/pal-composite.vert.glsl +8 -0
- package/src/video-filters/shaders/passthrough.frag.glsl +6 -0
- package/src/video-filters/shaders/passthrough.vert.glsl +7 -0
- package/src/video.js +794 -0
- package/src/wd-fdc.js +1344 -0
- package/src/web/audio-handler.js +146 -0
- package/src/web/audio-renderer.js +115 -0
- package/src/web/debug.js +529 -0
- package/tests/integration/RmwX.asm +47 -0
- package/tests/integration/TestInstructionsSource +27 -0
- package/tests/integration/TestTimingsResults +27 -0
- package/tests/integration/TestTimingsSource +61 -0
- package/tests/integration/bcd.js +23 -0
- package/tests/integration/dormann.js +101 -0
- package/tests/integration/dp111timing.js +42 -0
- package/tests/integration/ensure-submodules.js +25 -0
- package/tests/integration/nops.bas +119 -0
- package/tests/integration/nops.js +24 -0
- package/tests/integration/protection.js +26 -0
- package/tests/integration/rmw.js +69 -0
- package/tests/integration/teletext/expected_bug_469.png +0 -0
- package/tests/integration/teletext/expected_flash_0.png +0 -0
- package/tests/integration/teletext/expected_flash_1.png +0 -0
- package/tests/integration/teletext/expected_hoglet_held_char.png +0 -0
- package/tests/integration/teletext/expected_reveal_flash_0.png +0 -0
- package/tests/integration/teletext/expected_reveal_flash_1.png +0 -0
- package/tests/integration/teletext.js +126 -0
- package/tests/integration/timings.js +56 -0
- package/tests/integration/via.js +1125 -0
- package/tests/suite/README.md +7 -0
- package/tests/suite/Test Suite 2.15.txt +373 -0
- package/tests/suite/bin/ start +0 -0
- package/tests/suite/bin/adca +0 -0
- package/tests/suite/bin/adcax +0 -0
- package/tests/suite/bin/adcay +0 -0
- package/tests/suite/bin/adcb +0 -0
- package/tests/suite/bin/adcix +0 -0
- package/tests/suite/bin/adciy +0 -0
- package/tests/suite/bin/adcz +0 -0
- package/tests/suite/bin/adczx +0 -0
- package/tests/suite/bin/alrb +0 -0
- package/tests/suite/bin/ancb +0 -0
- package/tests/suite/bin/anda +0 -0
- package/tests/suite/bin/andax +0 -0
- package/tests/suite/bin/anday +0 -0
- package/tests/suite/bin/andb +0 -0
- package/tests/suite/bin/andix +0 -0
- package/tests/suite/bin/andiy +0 -0
- package/tests/suite/bin/andz +0 -0
- package/tests/suite/bin/andzx +0 -0
- package/tests/suite/bin/aneb +0 -0
- package/tests/suite/bin/arrb +0 -0
- package/tests/suite/bin/asla +0 -0
- package/tests/suite/bin/aslax +0 -0
- package/tests/suite/bin/asln +0 -0
- package/tests/suite/bin/aslz +0 -0
- package/tests/suite/bin/aslzx +0 -0
- package/tests/suite/bin/asoa +0 -0
- package/tests/suite/bin/asoax +0 -0
- package/tests/suite/bin/asoay +0 -0
- package/tests/suite/bin/asoix +0 -0
- package/tests/suite/bin/asoiy +0 -0
- package/tests/suite/bin/asoz +0 -0
- package/tests/suite/bin/asozx +0 -0
- package/tests/suite/bin/axsa +0 -0
- package/tests/suite/bin/axsix +0 -0
- package/tests/suite/bin/axsz +0 -0
- package/tests/suite/bin/axszy +0 -0
- package/tests/suite/bin/bccr +0 -0
- package/tests/suite/bin/bcsr +0 -0
- package/tests/suite/bin/beqr +0 -0
- package/tests/suite/bin/bita +0 -0
- package/tests/suite/bin/bitz +0 -0
- package/tests/suite/bin/bmir +0 -0
- package/tests/suite/bin/bner +0 -0
- package/tests/suite/bin/bplr +0 -0
- package/tests/suite/bin/branchwrap +0 -0
- package/tests/suite/bin/brkn +0 -0
- package/tests/suite/bin/bvcr +0 -0
- package/tests/suite/bin/bvsr +0 -0
- package/tests/suite/bin/cia1pb6 +0 -0
- package/tests/suite/bin/cia1pb7 +0 -0
- package/tests/suite/bin/cia1ta +0 -0
- package/tests/suite/bin/cia1tab +0 -0
- package/tests/suite/bin/cia1tb +0 -0
- package/tests/suite/bin/cia1tb123 +0 -0
- package/tests/suite/bin/cia2pb6 +0 -0
- package/tests/suite/bin/cia2pb7 +0 -0
- package/tests/suite/bin/cia2ta +0 -0
- package/tests/suite/bin/cia2tb +0 -0
- package/tests/suite/bin/cia2tb123 +0 -0
- package/tests/suite/bin/clcn +0 -0
- package/tests/suite/bin/cldn +0 -0
- package/tests/suite/bin/clin +0 -0
- package/tests/suite/bin/clvn +0 -0
- package/tests/suite/bin/cmpa +0 -0
- package/tests/suite/bin/cmpax +0 -0
- package/tests/suite/bin/cmpay +0 -0
- package/tests/suite/bin/cmpb +0 -0
- package/tests/suite/bin/cmpix +0 -0
- package/tests/suite/bin/cmpiy +0 -0
- package/tests/suite/bin/cmpz +0 -0
- package/tests/suite/bin/cmpzx +0 -0
- package/tests/suite/bin/cntdef +0 -0
- package/tests/suite/bin/cnto2 +0 -0
- package/tests/suite/bin/cpuport +0 -0
- package/tests/suite/bin/cputiming +0 -0
- package/tests/suite/bin/cpxa +0 -0
- package/tests/suite/bin/cpxb +0 -0
- package/tests/suite/bin/cpxz +0 -0
- package/tests/suite/bin/cpya +0 -0
- package/tests/suite/bin/cpyb +0 -0
- package/tests/suite/bin/cpyz +0 -0
- package/tests/suite/bin/dcma +0 -0
- package/tests/suite/bin/dcmax +0 -0
- package/tests/suite/bin/dcmay +0 -0
- package/tests/suite/bin/dcmix +0 -0
- package/tests/suite/bin/dcmiy +0 -0
- package/tests/suite/bin/dcmz +0 -0
- package/tests/suite/bin/dcmzx +0 -0
- package/tests/suite/bin/deca +0 -0
- package/tests/suite/bin/decax +0 -0
- package/tests/suite/bin/decz +0 -0
- package/tests/suite/bin/deczx +0 -0
- package/tests/suite/bin/dexn +0 -0
- package/tests/suite/bin/deyn +0 -0
- package/tests/suite/bin/eora +0 -0
- package/tests/suite/bin/eorax +0 -0
- package/tests/suite/bin/eoray +0 -0
- package/tests/suite/bin/eorb +0 -0
- package/tests/suite/bin/eorix +0 -0
- package/tests/suite/bin/eoriy +0 -0
- package/tests/suite/bin/eorz +0 -0
- package/tests/suite/bin/eorzx +0 -0
- package/tests/suite/bin/finish +0 -0
- package/tests/suite/bin/flipos +0 -0
- package/tests/suite/bin/icr01 +0 -0
- package/tests/suite/bin/imr +0 -0
- package/tests/suite/bin/inca +0 -0
- package/tests/suite/bin/incax +0 -0
- package/tests/suite/bin/incz +0 -0
- package/tests/suite/bin/inczx +0 -0
- package/tests/suite/bin/insa +0 -0
- package/tests/suite/bin/insax +0 -0
- package/tests/suite/bin/insay +0 -0
- package/tests/suite/bin/insix +0 -0
- package/tests/suite/bin/insiy +0 -0
- package/tests/suite/bin/insz +0 -0
- package/tests/suite/bin/inszx +0 -0
- package/tests/suite/bin/inxn +0 -0
- package/tests/suite/bin/inyn +0 -0
- package/tests/suite/bin/irq +0 -0
- package/tests/suite/bin/jmpi +0 -0
- package/tests/suite/bin/jmpw +0 -0
- package/tests/suite/bin/jsrw +0 -0
- package/tests/suite/bin/lasay +0 -0
- package/tests/suite/bin/laxa +0 -0
- package/tests/suite/bin/laxay +0 -0
- package/tests/suite/bin/laxix +0 -0
- package/tests/suite/bin/laxiy +0 -0
- package/tests/suite/bin/laxz +0 -0
- package/tests/suite/bin/laxzy +0 -0
- package/tests/suite/bin/ldaa +0 -0
- package/tests/suite/bin/ldaax +0 -0
- package/tests/suite/bin/ldaay +0 -0
- package/tests/suite/bin/ldab +0 -0
- package/tests/suite/bin/ldaix +0 -0
- package/tests/suite/bin/ldaiy +0 -0
- package/tests/suite/bin/ldaz +0 -0
- package/tests/suite/bin/ldazx +0 -0
- package/tests/suite/bin/ldxa +0 -0
- package/tests/suite/bin/ldxay +0 -0
- package/tests/suite/bin/ldxb +0 -0
- package/tests/suite/bin/ldxz +0 -0
- package/tests/suite/bin/ldxzy +0 -0
- package/tests/suite/bin/ldya +0 -0
- package/tests/suite/bin/ldyax +0 -0
- package/tests/suite/bin/ldyb +0 -0
- package/tests/suite/bin/ldyz +0 -0
- package/tests/suite/bin/ldyzx +0 -0
- package/tests/suite/bin/loadth +0 -0
- package/tests/suite/bin/lsea +0 -0
- package/tests/suite/bin/lseax +0 -0
- package/tests/suite/bin/lseay +0 -0
- package/tests/suite/bin/lseix +0 -0
- package/tests/suite/bin/lseiy +0 -0
- package/tests/suite/bin/lsez +0 -0
- package/tests/suite/bin/lsezx +0 -0
- package/tests/suite/bin/lsra +0 -0
- package/tests/suite/bin/lsrax +0 -0
- package/tests/suite/bin/lsrn +0 -0
- package/tests/suite/bin/lsrz +0 -0
- package/tests/suite/bin/lsrzx +0 -0
- package/tests/suite/bin/lxab +0 -0
- package/tests/suite/bin/mmu +0 -0
- package/tests/suite/bin/mmufetch +0 -0
- package/tests/suite/bin/nmi +0 -0
- package/tests/suite/bin/nopa +0 -0
- package/tests/suite/bin/nopax +0 -0
- package/tests/suite/bin/nopb +0 -0
- package/tests/suite/bin/nopn +0 -0
- package/tests/suite/bin/nopz +0 -0
- package/tests/suite/bin/nopzx +0 -0
- package/tests/suite/bin/oneshot +0 -0
- package/tests/suite/bin/oraa +0 -0
- package/tests/suite/bin/oraax +0 -0
- package/tests/suite/bin/oraay +0 -0
- package/tests/suite/bin/orab +0 -0
- package/tests/suite/bin/oraix +0 -0
- package/tests/suite/bin/oraiy +0 -0
- package/tests/suite/bin/oraz +0 -0
- package/tests/suite/bin/orazx +0 -0
- package/tests/suite/bin/phan +0 -0
- package/tests/suite/bin/phpn +0 -0
- package/tests/suite/bin/plan +0 -0
- package/tests/suite/bin/plpn +0 -0
- package/tests/suite/bin/rlaa +0 -0
- package/tests/suite/bin/rlaax +0 -0
- package/tests/suite/bin/rlaay +0 -0
- package/tests/suite/bin/rlaix +0 -0
- package/tests/suite/bin/rlaiy +0 -0
- package/tests/suite/bin/rlaz +0 -0
- package/tests/suite/bin/rlazx +0 -0
- package/tests/suite/bin/rola +0 -0
- package/tests/suite/bin/rolax +0 -0
- package/tests/suite/bin/roln +0 -0
- package/tests/suite/bin/rolz +0 -0
- package/tests/suite/bin/rolzx +0 -0
- package/tests/suite/bin/rora +0 -0
- package/tests/suite/bin/rorax +0 -0
- package/tests/suite/bin/rorn +0 -0
- package/tests/suite/bin/rorz +0 -0
- package/tests/suite/bin/rorzx +0 -0
- package/tests/suite/bin/rraa +0 -0
- package/tests/suite/bin/rraax +0 -0
- package/tests/suite/bin/rraay +0 -0
- package/tests/suite/bin/rraix +0 -0
- package/tests/suite/bin/rraiy +0 -0
- package/tests/suite/bin/rraz +0 -0
- package/tests/suite/bin/rrazx +0 -0
- package/tests/suite/bin/rtin +0 -0
- package/tests/suite/bin/rtsn +0 -0
- package/tests/suite/bin/sbca +0 -0
- package/tests/suite/bin/sbcax +0 -0
- package/tests/suite/bin/sbcay +0 -0
- package/tests/suite/bin/sbcb +0 -0
- package/tests/suite/bin/sbcb(eb) +0 -0
- package/tests/suite/bin/sbcix +0 -0
- package/tests/suite/bin/sbciy +0 -0
- package/tests/suite/bin/sbcz +0 -0
- package/tests/suite/bin/sbczx +0 -0
- package/tests/suite/bin/sbxb +0 -0
- package/tests/suite/bin/secn +0 -0
- package/tests/suite/bin/sedn +0 -0
- package/tests/suite/bin/sein +0 -0
- package/tests/suite/bin/shaay +0 -0
- package/tests/suite/bin/shaiy +0 -0
- package/tests/suite/bin/shsay +0 -0
- package/tests/suite/bin/shxay +0 -0
- package/tests/suite/bin/shyax +0 -0
- package/tests/suite/bin/staa +0 -0
- package/tests/suite/bin/staax +0 -0
- package/tests/suite/bin/staay +0 -0
- package/tests/suite/bin/staix +0 -0
- package/tests/suite/bin/staiy +0 -0
- package/tests/suite/bin/staz +0 -0
- package/tests/suite/bin/stazx +0 -0
- package/tests/suite/bin/stxa +0 -0
- package/tests/suite/bin/stxz +0 -0
- package/tests/suite/bin/stxzy +0 -0
- package/tests/suite/bin/stya +0 -0
- package/tests/suite/bin/styz +0 -0
- package/tests/suite/bin/styzx +0 -0
- package/tests/suite/bin/taxn +0 -0
- package/tests/suite/bin/tayn +0 -0
- package/tests/suite/bin/trap1 +0 -0
- package/tests/suite/bin/trap10 +0 -0
- package/tests/suite/bin/trap11 +0 -0
- package/tests/suite/bin/trap12 +0 -0
- package/tests/suite/bin/trap13 +0 -0
- package/tests/suite/bin/trap14 +0 -0
- package/tests/suite/bin/trap15 +0 -0
- package/tests/suite/bin/trap16 +0 -0
- package/tests/suite/bin/trap17 +0 -0
- package/tests/suite/bin/trap2 +0 -0
- package/tests/suite/bin/trap3 +0 -0
- package/tests/suite/bin/trap4 +0 -0
- package/tests/suite/bin/trap5 +0 -0
- package/tests/suite/bin/trap6 +0 -0
- package/tests/suite/bin/trap7 +0 -0
- package/tests/suite/bin/trap8 +0 -0
- package/tests/suite/bin/trap9 +0 -0
- package/tests/suite/bin/tsxn +0 -0
- package/tests/suite/bin/txan +0 -0
- package/tests/suite/bin/txsn +0 -0
- package/tests/suite/bin/tyan +0 -0
- package/tests/suite/cbm-hackers-post.html +178 -0
- package/tests/suite/cbm-hackers-post.md +78 -0
- package/tests/test-machine.js +288 -0
- package/tests/test-suite.js +147 -0
- package/tests/test.css +7 -0
- package/tests/unit/gzip/test-1 +0 -0
- package/tests/unit/gzip/test-1.gz +0 -0
- package/tests/unit/gzip/test-2 +0 -0
- package/tests/unit/gzip/test-2.gz +0 -0
- package/tests/unit/gzip/test-3 +0 -0
- package/tests/unit/gzip/test-3.gz +0 -0
- package/tests/unit/gzip/test-4 +0 -0
- package/tests/unit/gzip/test-4.gz +0 -0
- package/tests/unit/test-adc.js +307 -0
- package/tests/unit/test-bcd.js +30 -0
- package/tests/unit/test-cmos.js +266 -0
- package/tests/unit/test-disc-drive.js +85 -0
- package/tests/unit/test-disc-hfe.js +347 -0
- package/tests/unit/test-disc.js +232 -0
- package/tests/unit/test-fifo.js +35 -0
- package/tests/unit/test-gamepad-source.js +67 -0
- package/tests/unit/test-gzip.js +22 -0
- package/tests/unit/test-intel-fdc.js +93 -0
- package/tests/unit/test-keyboard.js +410 -0
- package/tests/unit/test-mouse-joystick-source.js +128 -0
- package/tests/unit/test-scheduler.js +190 -0
- package/tests/unit/test-serial.js +154 -0
- package/tests/unit/test-teletext-adaptor.js +359 -0
- package/tests/unit/test-tokenise.js +65 -0
- package/tests/unit/test-url-params.js +398 -0
- package/tests/unit/test-utils.js +276 -0
- package/tests/unit/test-video.js +498 -0
- package/tests/unit/test-zip.js +56 -0
- package/tests/unit/zip/test-mixed.zip +0 -0
- package/tests/unit/zip/test-rom.zip +0 -0
- package/tests/unit/zip/test-ssd.zip +0 -0
- package/tools/fir-generator.js +80 -0
- package/tools/vite-plugin-fir-shader.js +131 -0
- package/vite.config.js +34 -0
package/src/main.js
ADDED
|
@@ -0,0 +1,1759 @@
|
|
|
1
|
+
import $ from "jquery";
|
|
2
|
+
import _ from "underscore";
|
|
3
|
+
import * as bootstrap from "bootstrap";
|
|
4
|
+
import { version } from "../package.json";
|
|
5
|
+
|
|
6
|
+
import "bootswatch/dist/darkly/bootstrap.min.css";
|
|
7
|
+
import "./jsbeeb.css";
|
|
8
|
+
|
|
9
|
+
import * as utils from "./utils.js";
|
|
10
|
+
import { FakeVideo, Video } from "./video.js";
|
|
11
|
+
import { Debugger } from "./web/debug.js";
|
|
12
|
+
import { Cpu6502 } from "./6502.js";
|
|
13
|
+
import { Cmos } from "./cmos.js";
|
|
14
|
+
import { StairwayToHell } from "./sth.js";
|
|
15
|
+
import { GamePad } from "./gamepads.js";
|
|
16
|
+
import * as disc from "./fdc.js";
|
|
17
|
+
import { loadTape, loadTapeFromData } from "./tapes.js";
|
|
18
|
+
import { GoogleDriveLoader } from "./google-drive.js";
|
|
19
|
+
import * as tokeniser from "./basic-tokenise.js";
|
|
20
|
+
import * as canvasLib from "./canvas.js";
|
|
21
|
+
import { Config } from "./config.js";
|
|
22
|
+
import { initialise as electron } from "./app/electron.js";
|
|
23
|
+
import { AudioHandler } from "./web/audio-handler.js";
|
|
24
|
+
import { Econet } from "./econet.js";
|
|
25
|
+
import { toSsdOrDsd } from "./disc.js";
|
|
26
|
+
import { toHfe } from "./disc-hfe.js";
|
|
27
|
+
import { Keyboard } from "./keyboard.js";
|
|
28
|
+
import { GamepadSource } from "./gamepad-source.js";
|
|
29
|
+
import { MicrophoneInput } from "./microphone-input.js";
|
|
30
|
+
import { MouseJoystickSource } from "./mouse-joystick-source.js";
|
|
31
|
+
import { getFilterForMode } from "./canvas.js";
|
|
32
|
+
import {
|
|
33
|
+
buildUrlFromParams,
|
|
34
|
+
guessModelFromHostname,
|
|
35
|
+
ParamTypes,
|
|
36
|
+
parseMediaParams,
|
|
37
|
+
parseQueryString,
|
|
38
|
+
processAutobootParams,
|
|
39
|
+
processKeyboardParams,
|
|
40
|
+
} from "./url-params.js";
|
|
41
|
+
|
|
42
|
+
let processor;
|
|
43
|
+
let video;
|
|
44
|
+
const dbgr = new Debugger();
|
|
45
|
+
let frames = 0;
|
|
46
|
+
let frameSkip = 0;
|
|
47
|
+
let syncLights;
|
|
48
|
+
let discSth;
|
|
49
|
+
let tapeSth;
|
|
50
|
+
let running;
|
|
51
|
+
let model;
|
|
52
|
+
const gamepad = new GamePad();
|
|
53
|
+
const availableImages = [
|
|
54
|
+
{
|
|
55
|
+
name: "Elite",
|
|
56
|
+
desc: "An 8-bit classic. Hit F10 to launch from the space station, then use <, >, S, X and A to fly around.",
|
|
57
|
+
file: "elite.ssd",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: "Welcome",
|
|
61
|
+
desc: "The disc supplied with BBC Disc systems to demonstrate some of the features of the system.",
|
|
62
|
+
file: "Welcome.ssd",
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: "Music 5000",
|
|
66
|
+
desc: "The Music 5000 system disk and demo songs.",
|
|
67
|
+
file: "5000mstr36008.ssd",
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
let discImage = availableImages[0].file;
|
|
71
|
+
const extraRoms = [];
|
|
72
|
+
|
|
73
|
+
// Build the query string from the URL
|
|
74
|
+
const queryString = document.location.search.substring(1) + "&" + window.location.hash.substring(1);
|
|
75
|
+
let secondDiscImage = null;
|
|
76
|
+
|
|
77
|
+
// Define parameter types
|
|
78
|
+
const paramTypes = {
|
|
79
|
+
// Array parameters
|
|
80
|
+
rom: ParamTypes.ARRAY,
|
|
81
|
+
|
|
82
|
+
// Boolean parameters
|
|
83
|
+
embed: ParamTypes.BOOL,
|
|
84
|
+
fasttape: ParamTypes.BOOL,
|
|
85
|
+
noseek: ParamTypes.BOOL,
|
|
86
|
+
debug: ParamTypes.BOOL,
|
|
87
|
+
verbose: ParamTypes.BOOL,
|
|
88
|
+
autoboot: ParamTypes.BOOL,
|
|
89
|
+
autochain: ParamTypes.BOOL,
|
|
90
|
+
autorun: ParamTypes.BOOL,
|
|
91
|
+
hasMusic5000: ParamTypes.BOOL,
|
|
92
|
+
hasTeletextAdaptor: ParamTypes.BOOL,
|
|
93
|
+
hasEconet: ParamTypes.BOOL,
|
|
94
|
+
glEnabled: ParamTypes.BOOL,
|
|
95
|
+
fakeVideo: ParamTypes.BOOL,
|
|
96
|
+
logFdcCommands: ParamTypes.BOOL,
|
|
97
|
+
logFdcStateChanges: ParamTypes.BOOL,
|
|
98
|
+
coProcessor: ParamTypes.BOOL,
|
|
99
|
+
mouseJoystickEnabled: ParamTypes.BOOL,
|
|
100
|
+
|
|
101
|
+
// Numeric parameters
|
|
102
|
+
speed: ParamTypes.INT,
|
|
103
|
+
stationId: ParamTypes.INT,
|
|
104
|
+
frameSkip: ParamTypes.INT,
|
|
105
|
+
audiofilterfreq: ParamTypes.FLOAT,
|
|
106
|
+
audiofilterq: ParamTypes.FLOAT,
|
|
107
|
+
cpuMultiplier: ParamTypes.FLOAT,
|
|
108
|
+
microphoneChannel: ParamTypes.INT,
|
|
109
|
+
|
|
110
|
+
// String parameters (these are the default but listed for clarity)
|
|
111
|
+
model: ParamTypes.STRING,
|
|
112
|
+
disc: ParamTypes.STRING,
|
|
113
|
+
disc1: ParamTypes.STRING,
|
|
114
|
+
disc2: ParamTypes.STRING,
|
|
115
|
+
tape: ParamTypes.STRING,
|
|
116
|
+
keyLayout: ParamTypes.STRING,
|
|
117
|
+
autotype: ParamTypes.STRING,
|
|
118
|
+
displayMode: ParamTypes.STRING,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Parse the query string with parameter types
|
|
122
|
+
let parsedQuery = parseQueryString(queryString, paramTypes);
|
|
123
|
+
let { needsAutoboot, autoType } = processAutobootParams(parsedQuery);
|
|
124
|
+
let keyLayout = window.localStorage.keyLayout || "physical";
|
|
125
|
+
|
|
126
|
+
const BBC = utils.BBC;
|
|
127
|
+
const keyCodes = utils.keyCodes;
|
|
128
|
+
const emuKeyHandlers = {};
|
|
129
|
+
let cpuMultiplier = 1;
|
|
130
|
+
let fastAsPossible = false;
|
|
131
|
+
let fastTape = false;
|
|
132
|
+
let noSeek = false;
|
|
133
|
+
let audioFilterFreq = 7000;
|
|
134
|
+
let audioFilterQ = 5;
|
|
135
|
+
let stationId = 101;
|
|
136
|
+
let econet = null;
|
|
137
|
+
|
|
138
|
+
// Parse disc and tape images from query parameters
|
|
139
|
+
const { discImage: queryDiscImage, secondDiscImage: querySecondDisc } = parseMediaParams(parsedQuery);
|
|
140
|
+
|
|
141
|
+
// Only assign if values are provided
|
|
142
|
+
if (queryDiscImage) discImage = queryDiscImage;
|
|
143
|
+
if (querySecondDisc) secondDiscImage = querySecondDisc;
|
|
144
|
+
|
|
145
|
+
// Process keyboard mappings
|
|
146
|
+
parsedQuery = processKeyboardParams(parsedQuery, BBC, keyCodes, utils.userKeymap, gamepad);
|
|
147
|
+
|
|
148
|
+
// Handle specific query parameters
|
|
149
|
+
if (Array.isArray(parsedQuery.rom)) {
|
|
150
|
+
parsedQuery.rom.forEach((romPath) => {
|
|
151
|
+
if (romPath) extraRoms.push(romPath);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
if (parsedQuery.keyLayout) {
|
|
155
|
+
keyLayout = (parsedQuery.keyLayout + "").toLowerCase();
|
|
156
|
+
}
|
|
157
|
+
if (parsedQuery.embed) {
|
|
158
|
+
$(".embed-hide").hide();
|
|
159
|
+
$("body").css("background-color", "transparent");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
fastTape = !!parsedQuery.fasttape;
|
|
163
|
+
noSeek = !!parsedQuery.noseek;
|
|
164
|
+
|
|
165
|
+
if (parsedQuery.audiofilterfreq !== undefined) audioFilterFreq = parsedQuery.audiofilterfreq;
|
|
166
|
+
if (parsedQuery.audiofilterq !== undefined) audioFilterQ = parsedQuery.audiofilterq;
|
|
167
|
+
if (parsedQuery.stationId !== undefined) stationId = parsedQuery.stationId;
|
|
168
|
+
if (parsedQuery.frameSkip !== undefined) frameSkip = parsedQuery.frameSkip;
|
|
169
|
+
|
|
170
|
+
const printerPort = {
|
|
171
|
+
outputStrobe: function (level, output) {
|
|
172
|
+
if (!printerTextArea) return;
|
|
173
|
+
if (!output || level) return;
|
|
174
|
+
|
|
175
|
+
const uservia = processor.uservia;
|
|
176
|
+
// Ack the character by pulsing CA1 low.
|
|
177
|
+
uservia.setca1(false);
|
|
178
|
+
uservia.setca1(true);
|
|
179
|
+
const newChar = String.fromCharCode(uservia.ora);
|
|
180
|
+
printerTextArea.value += newChar;
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
let userPort = null;
|
|
185
|
+
|
|
186
|
+
const keyswitch = true;
|
|
187
|
+
if (keyswitch) {
|
|
188
|
+
let switchState = 0xff;
|
|
189
|
+
|
|
190
|
+
const switchKey = function (down, code) {
|
|
191
|
+
const bit = 1 << (code - utils.keyCodes.K1);
|
|
192
|
+
if (down) switchState &= 0xff ^ bit;
|
|
193
|
+
else switchState |= bit;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
for (let idx = utils.keyCodes.K1; idx <= utils.keyCodes.K8; ++idx) {
|
|
197
|
+
emuKeyHandlers[idx] = switchKey;
|
|
198
|
+
}
|
|
199
|
+
userPort = {
|
|
200
|
+
write: function () {},
|
|
201
|
+
read: function () {
|
|
202
|
+
return switchState;
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const emulationConfig = {
|
|
208
|
+
keyLayout: keyLayout,
|
|
209
|
+
coProcessor: parsedQuery.coProcessor,
|
|
210
|
+
cpuMultiplier: cpuMultiplier,
|
|
211
|
+
videoCyclesBatch: parsedQuery.videoCyclesBatch,
|
|
212
|
+
extraRoms: extraRoms,
|
|
213
|
+
userPort: userPort,
|
|
214
|
+
printerPort: printerPort,
|
|
215
|
+
getGamepads: function () {
|
|
216
|
+
// Gamepads are only available in secure contexts. If e.g. loading from http:// urls they aren't there.
|
|
217
|
+
return navigator.getGamepads ? navigator.getGamepads() : [];
|
|
218
|
+
},
|
|
219
|
+
debugFlags: {
|
|
220
|
+
logFdcCommands: parsedQuery.logFdcCommands !== undefined,
|
|
221
|
+
logFdcStateChanges: parsedQuery.logFdcStateChanges !== undefined,
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const config = new Config(
|
|
226
|
+
function onChange(changed) {
|
|
227
|
+
if (changed.displayMode) {
|
|
228
|
+
displayModeFilter = getFilterForMode(changed.displayMode);
|
|
229
|
+
setCrtPic(displayModeFilter);
|
|
230
|
+
swapCanvas(displayModeFilter);
|
|
231
|
+
// Trigger window resize to recalculate layout with new dimensions
|
|
232
|
+
$(window).trigger("resize");
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
function onClose(changed) {
|
|
236
|
+
parsedQuery = _.extend(parsedQuery, changed);
|
|
237
|
+
if (
|
|
238
|
+
changed.model ||
|
|
239
|
+
changed.coProcessor !== undefined ||
|
|
240
|
+
changed.hasMusic5000 !== undefined ||
|
|
241
|
+
changed.hasTeletextAdaptor !== undefined ||
|
|
242
|
+
changed.hasEconet !== undefined
|
|
243
|
+
) {
|
|
244
|
+
areYouSure(
|
|
245
|
+
"Changing model requires a restart of the emulator. Restart now?",
|
|
246
|
+
"Yes, restart now",
|
|
247
|
+
"No, thanks",
|
|
248
|
+
function () {
|
|
249
|
+
updateUrl();
|
|
250
|
+
window.location.reload();
|
|
251
|
+
},
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
if (changed.keyLayout) {
|
|
255
|
+
window.localStorage.keyLayout = changed.keyLayout;
|
|
256
|
+
emulationConfig.keyLayout = changed.keyLayout;
|
|
257
|
+
keyboard.setKeyLayout(changed.keyLayout);
|
|
258
|
+
}
|
|
259
|
+
if (changed.mouseJoystickEnabled !== undefined || changed.microphoneChannel !== undefined) {
|
|
260
|
+
updateAdcSources(parsedQuery.mouseJoystickEnabled, parsedQuery.microphoneChannel);
|
|
261
|
+
|
|
262
|
+
if (changed.microphoneChannel !== undefined) {
|
|
263
|
+
setupMicrophone().then(() => {});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
updateUrl();
|
|
267
|
+
},
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
// Perform mapping of legacy models to the new format
|
|
271
|
+
config.mapLegacyModels(parsedQuery);
|
|
272
|
+
|
|
273
|
+
config.setModel(parsedQuery.model || guessModelFromHostname(window.location.hostname));
|
|
274
|
+
config.setKeyLayout(keyLayout);
|
|
275
|
+
config.set65c02(parsedQuery.coProcessor);
|
|
276
|
+
config.setEconet(parsedQuery.hasEconet);
|
|
277
|
+
config.setMusic5000(parsedQuery.hasMusic5000);
|
|
278
|
+
config.setTeletext(parsedQuery.hasTeletextAdaptor);
|
|
279
|
+
config.setMicrophoneChannel(parsedQuery.microphoneChannel);
|
|
280
|
+
config.setMouseJoystickEnabled(parsedQuery.mouseJoystickEnabled);
|
|
281
|
+
let displayMode = parsedQuery.displayMode || "rgb";
|
|
282
|
+
config.setDisplayMode(displayMode);
|
|
283
|
+
|
|
284
|
+
model = config.model;
|
|
285
|
+
|
|
286
|
+
function sbBind(div, url, onload) {
|
|
287
|
+
const img = div.find("img");
|
|
288
|
+
img.hide();
|
|
289
|
+
if (!url) return;
|
|
290
|
+
img.attr("src", url).bind("load", function () {
|
|
291
|
+
onload(div, img);
|
|
292
|
+
img.show();
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
sbBind($(".sidebar.left"), parsedQuery.sbLeft, function (div, img) {
|
|
297
|
+
div.css({ left: -img.width() - 5 });
|
|
298
|
+
});
|
|
299
|
+
sbBind($(".sidebar.right"), parsedQuery.sbRight, function (div, img) {
|
|
300
|
+
div.css({ right: -img.width() - 5 });
|
|
301
|
+
});
|
|
302
|
+
sbBind($(".sidebar.bottom"), parsedQuery.sbBottom, function (div, img) {
|
|
303
|
+
div.css({ bottom: -img.height() });
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
if (parsedQuery.cpuMultiplier !== undefined) {
|
|
307
|
+
cpuMultiplier = parsedQuery.cpuMultiplier;
|
|
308
|
+
console.log("CPU multiplier set to " + cpuMultiplier);
|
|
309
|
+
}
|
|
310
|
+
const clocksPerSecond = (cpuMultiplier * 2 * 1000 * 1000) | 0;
|
|
311
|
+
const MaxCyclesPerFrame = clocksPerSecond / 10;
|
|
312
|
+
|
|
313
|
+
let tryGl = true;
|
|
314
|
+
if (parsedQuery.glEnabled !== undefined) {
|
|
315
|
+
tryGl = parsedQuery.glEnabled === "true";
|
|
316
|
+
}
|
|
317
|
+
const $screen = $("#screen");
|
|
318
|
+
|
|
319
|
+
const $errorDialog = $("#error-dialog");
|
|
320
|
+
const $errorDialogModal = new bootstrap.Modal($errorDialog[0]);
|
|
321
|
+
|
|
322
|
+
function showError(context, error) {
|
|
323
|
+
$errorDialog.find(".context").text(context);
|
|
324
|
+
$errorDialog.find(".error").text(error);
|
|
325
|
+
$errorDialogModal.show();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function createCanvasForFilter(filterClass) {
|
|
329
|
+
const newCanvas = tryGl ? canvasLib.bestCanvas($screen[0], filterClass) : new canvasLib.Canvas($screen[0]);
|
|
330
|
+
|
|
331
|
+
if (filterClass.requiresGl() && !newCanvas.isWebGl()) {
|
|
332
|
+
const config = filterClass.getDisplayConfig();
|
|
333
|
+
showError(`enabling ${config.name} mode`, `${config.name} requires WebGL. Using standard display instead.`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return newCanvas;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
let displayModeFilter = canvasLib.getFilterForMode(parsedQuery.displayMode || "rgb");
|
|
340
|
+
function swapCanvas(newFilterClass) {
|
|
341
|
+
const newCanvas = createCanvasForFilter(newFilterClass);
|
|
342
|
+
video.fb32 = newCanvas.fb32;
|
|
343
|
+
video.paint_ext = function paint(minx, miny, maxx, maxy) {
|
|
344
|
+
frames++;
|
|
345
|
+
if (frames < frameSkip) return;
|
|
346
|
+
frames = 0;
|
|
347
|
+
newCanvas.paint(minx, miny, maxx, maxy, this.frameCount);
|
|
348
|
+
};
|
|
349
|
+
canvas = newCanvas;
|
|
350
|
+
displayModeFilter = newFilterClass;
|
|
351
|
+
window.setTimeout(() => window.onresize(), 1);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
let canvas = createCanvasForFilter(displayModeFilter);
|
|
355
|
+
|
|
356
|
+
video = new Video(model.isMaster, canvas.fb32, function paint(minx, miny, maxx, maxy) {
|
|
357
|
+
frames++;
|
|
358
|
+
if (frames < frameSkip) return;
|
|
359
|
+
frames = 0;
|
|
360
|
+
canvas.paint(minx, miny, maxx, maxy, this.frameCount);
|
|
361
|
+
});
|
|
362
|
+
if (parsedQuery.fakeVideo !== undefined) video = new FakeVideo();
|
|
363
|
+
|
|
364
|
+
const audioStatsNode = document.getElementById("audio-stats");
|
|
365
|
+
const audioHandler = new AudioHandler($("#audio-warning"), audioStatsNode, audioFilterFreq, audioFilterQ, noSeek);
|
|
366
|
+
if (!parsedQuery.audioDebug) audioStatsNode.style.display = "none";
|
|
367
|
+
// Firefox will report that audio is suspended even when it will
|
|
368
|
+
// start playing without user interaction, so we need to delay a
|
|
369
|
+
// little to get a reliable indication.
|
|
370
|
+
window.setTimeout(() => audioHandler.checkStatus(), 1000);
|
|
371
|
+
|
|
372
|
+
$(".initially-hidden").removeClass("initially-hidden");
|
|
373
|
+
|
|
374
|
+
const $discsModal = new bootstrap.Modal(document.getElementById("discs"));
|
|
375
|
+
const $fsModal = new bootstrap.Modal(document.getElementById("econetfs"));
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Helper function to read a file as binary string
|
|
379
|
+
* @param {File} file - The file to read
|
|
380
|
+
* @returns {Promise<string>} - Promise that resolves with the binary string content of the file, or rejects on error
|
|
381
|
+
*/
|
|
382
|
+
function readFileAsBinaryString(file) {
|
|
383
|
+
return new Promise((resolve, reject) => {
|
|
384
|
+
const reader = new FileReader();
|
|
385
|
+
reader.onload = (e) => {
|
|
386
|
+
resolve(e.target.result);
|
|
387
|
+
};
|
|
388
|
+
reader.onerror = (e) => {
|
|
389
|
+
console.error(`Error reading file ${file.name}:`, e);
|
|
390
|
+
reject(new Error(`Failed to read file ${file.name}`));
|
|
391
|
+
};
|
|
392
|
+
reader.readAsBinaryString(file);
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function replaceOrAddExtension(name, newExt) {
|
|
397
|
+
const lastDot = name.lastIndexOf(".");
|
|
398
|
+
if (lastDot === -1) {
|
|
399
|
+
return name + newExt;
|
|
400
|
+
}
|
|
401
|
+
return name.substring(0, lastDot) + newExt;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Helper function to download drive data in the specified format
|
|
406
|
+
* @param {Uint8Array} data - The binary data to download
|
|
407
|
+
* @param {string} name - The file name
|
|
408
|
+
* @param {string} extension - The file extension to use
|
|
409
|
+
*/
|
|
410
|
+
function downloadDriveData(data, name, extension) {
|
|
411
|
+
const a = document.createElement("a");
|
|
412
|
+
document.body.appendChild(a);
|
|
413
|
+
a.style = "display: none";
|
|
414
|
+
|
|
415
|
+
const fileName = replaceOrAddExtension(name, extension);
|
|
416
|
+
const blob = new Blob([data], { type: "application/octet-stream" });
|
|
417
|
+
const url = window.URL.createObjectURL(blob);
|
|
418
|
+
|
|
419
|
+
a.href = url;
|
|
420
|
+
a.download = fileName;
|
|
421
|
+
a.click();
|
|
422
|
+
window.URL.revokeObjectURL(url);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function loadHTMLFile(file) {
|
|
426
|
+
const binaryData = await readFileAsBinaryString(file);
|
|
427
|
+
processor.fdc.loadDisc(0, disc.discFor(processor.fdc, file.name, binaryData));
|
|
428
|
+
delete parsedQuery.disc;
|
|
429
|
+
delete parsedQuery.disc1;
|
|
430
|
+
updateUrl();
|
|
431
|
+
$discsModal.hide();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async function loadSCSIFile(file) {
|
|
435
|
+
const binaryData = await readFileAsBinaryString(file);
|
|
436
|
+
processor.filestore.scsi = utils.stringToUint8Array(binaryData);
|
|
437
|
+
|
|
438
|
+
processor.filestore.PC = 0x400;
|
|
439
|
+
processor.filestore.SP = 0xff;
|
|
440
|
+
processor.filestore.A = 1;
|
|
441
|
+
processor.filestore.emulationSpeed = 0;
|
|
442
|
+
|
|
443
|
+
// Reset any open receive blocks
|
|
444
|
+
processor.econet.receiveBlocks = [];
|
|
445
|
+
processor.econet.nextReceiveBlockNumber = 1;
|
|
446
|
+
|
|
447
|
+
$fsModal.hide();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const $pastetext = $("#paste-text");
|
|
451
|
+
$pastetext.on("paste", function (event) {
|
|
452
|
+
const text = event.originalEvent.clipboardData.getData("text/plain");
|
|
453
|
+
sendRawKeyboardToBBC(utils.stringToBBCKeys(text), true);
|
|
454
|
+
});
|
|
455
|
+
$pastetext.on("dragover", function (event) {
|
|
456
|
+
event.preventDefault();
|
|
457
|
+
event.stopPropagation();
|
|
458
|
+
event.originalEvent.dataTransfer.dropEffect = "copy";
|
|
459
|
+
});
|
|
460
|
+
$pastetext.on("drop", async function (event) {
|
|
461
|
+
utils.noteEvent("local", "drop");
|
|
462
|
+
const file = event.originalEvent.dataTransfer.files[0];
|
|
463
|
+
await loadHTMLFile(file);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
const $cub = $("#cub-monitor");
|
|
467
|
+
$cub.on("mousemove mousedown mouseup", function (evt) {
|
|
468
|
+
audioHandler.tryResume().then(() => {});
|
|
469
|
+
if (document.activeElement !== document.body) document.activeElement.blur();
|
|
470
|
+
const cubOffset = $cub.offset();
|
|
471
|
+
const screenOffset = $screen.offset();
|
|
472
|
+
const x = (evt.offsetX - cubOffset.left + screenOffset.left) / $screen.width();
|
|
473
|
+
const y = (evt.offsetY - cubOffset.top + screenOffset.top) / $screen.height();
|
|
474
|
+
|
|
475
|
+
// Handle touchscreen
|
|
476
|
+
if (processor.touchScreen) processor.touchScreen.onMouse(x, y, evt.buttons);
|
|
477
|
+
|
|
478
|
+
// Handle mouse joystick if enabled
|
|
479
|
+
if (parsedQuery.mouseJoystickEnabled && mouseJoystickSource.isEnabled()) {
|
|
480
|
+
// Use the API methods instead of direct manipulation
|
|
481
|
+
mouseJoystickSource.onMouseMove(x, y);
|
|
482
|
+
|
|
483
|
+
// Handle button events
|
|
484
|
+
if (evt.type === "mousedown" && evt.button === 0) {
|
|
485
|
+
mouseJoystickSource.onMouseDown(0);
|
|
486
|
+
} else if (evt.type === "mouseup" && evt.button === 0) {
|
|
487
|
+
mouseJoystickSource.onMouseUp(0);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
evt.preventDefault();
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
function setCrtPic(filterMode) {
|
|
495
|
+
const config = filterMode.getDisplayConfig();
|
|
496
|
+
const $monitorPic = $("#cub-monitor-pic");
|
|
497
|
+
$monitorPic.attr("src", config.image);
|
|
498
|
+
$monitorPic.attr("alt", config.imageAlt);
|
|
499
|
+
$monitorPic.attr("width", config.imageWidth);
|
|
500
|
+
$monitorPic.attr("height", config.imageHeight);
|
|
501
|
+
}
|
|
502
|
+
setCrtPic(displayModeFilter);
|
|
503
|
+
|
|
504
|
+
$(window).blur(function () {
|
|
505
|
+
keyboard.clearKeys();
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
$("#fs").click(function (event) {
|
|
509
|
+
$screen[0].requestFullscreen();
|
|
510
|
+
event.preventDefault();
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
let keyboard; // This will be initialised after the processor is created
|
|
514
|
+
|
|
515
|
+
const $debugPause = $("#debug-pause");
|
|
516
|
+
const $debugPlay = $("#debug-play");
|
|
517
|
+
$debugPause.click(() => stop(true));
|
|
518
|
+
$debugPlay.click(() => {
|
|
519
|
+
dbgr.hide();
|
|
520
|
+
go();
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// To lower chance of data loss, only accept drop events in the drop
|
|
524
|
+
// zone in the menu bar.
|
|
525
|
+
document.addEventListener("dragover", function (event) {
|
|
526
|
+
event.preventDefault();
|
|
527
|
+
event.dataTransfer.dropEffect = "none";
|
|
528
|
+
});
|
|
529
|
+
document.addEventListener("drop", function (event) {
|
|
530
|
+
event.preventDefault();
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
window.addEventListener("beforeunload", function (event) {
|
|
534
|
+
if (running && processor.sysvia.hasAnyKeyDown()) {
|
|
535
|
+
const message =
|
|
536
|
+
"It seems like you're still using the emulator. If you're in Chrome, it's impossible for jsbeeb to prevent some shortcuts (like ctrl-W) from performing their default behaviour (e.g. closing the window).\n" +
|
|
537
|
+
"As a workarond, create an 'Application Shortcut' from the Tools menu. When jsbeeb runs as an application, it *can* prevent ctrl-W from closing the window.";
|
|
538
|
+
event.preventDefault();
|
|
539
|
+
event.returnValue = message;
|
|
540
|
+
return message;
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
if (model.hasEconet) {
|
|
545
|
+
econet = new Econet(stationId);
|
|
546
|
+
} else {
|
|
547
|
+
$("#fsmenuitem").hide();
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const cmos = new Cmos(
|
|
551
|
+
{
|
|
552
|
+
load: function () {
|
|
553
|
+
if (window.localStorage.cmosRam) {
|
|
554
|
+
return JSON.parse(window.localStorage.cmosRam);
|
|
555
|
+
}
|
|
556
|
+
return null;
|
|
557
|
+
},
|
|
558
|
+
save: function (data) {
|
|
559
|
+
window.localStorage.cmosRam = JSON.stringify(data);
|
|
560
|
+
},
|
|
561
|
+
},
|
|
562
|
+
model.cmosOverride,
|
|
563
|
+
econet,
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
let printerWindow = null;
|
|
567
|
+
let printerTextArea = null;
|
|
568
|
+
|
|
569
|
+
function checkPrinterWindow() {
|
|
570
|
+
if (printerWindow && !printerWindow.closed) return;
|
|
571
|
+
|
|
572
|
+
printerWindow = window.open("", "_blank", "height=300,width=400");
|
|
573
|
+
printerWindow.document.write(
|
|
574
|
+
'<textarea id="text" rows="15" cols="40" placeholder="Printer outputs here..."></textarea>',
|
|
575
|
+
);
|
|
576
|
+
printerTextArea = printerWindow.document.getElementById("text");
|
|
577
|
+
|
|
578
|
+
processor.uservia.setca1(true);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
processor = new Cpu6502(
|
|
582
|
+
model,
|
|
583
|
+
dbgr,
|
|
584
|
+
video,
|
|
585
|
+
audioHandler.soundChip,
|
|
586
|
+
audioHandler.ddNoise,
|
|
587
|
+
model.hasMusic5000 ? audioHandler.music5000 : null,
|
|
588
|
+
cmos,
|
|
589
|
+
emulationConfig,
|
|
590
|
+
econet,
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
// Create input sources
|
|
594
|
+
const gamepadSource = new GamepadSource(emulationConfig.getGamepads);
|
|
595
|
+
|
|
596
|
+
// Create MicrophoneInput but don't enable by default
|
|
597
|
+
const microphoneInput = new MicrophoneInput();
|
|
598
|
+
microphoneInput.setErrorCallback((message) => {
|
|
599
|
+
showError("accessing microphone", message);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
// Create MouseJoystickSource but don't enable by default
|
|
603
|
+
const screenCanvas = document.getElementById("screen");
|
|
604
|
+
const mouseJoystickSource = new MouseJoystickSource(screenCanvas);
|
|
605
|
+
|
|
606
|
+
// Helper to manage ADC source configuration
|
|
607
|
+
function updateAdcSources(mouseJoystickEnabled, microphoneChannel) {
|
|
608
|
+
// Default all channels to gamepad
|
|
609
|
+
for (let ch = 0; ch < 4; ch++) {
|
|
610
|
+
processor.adconverter.setChannelSource(ch, gamepadSource);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Apply mouse joystick if enabled (takes priority on channels 0 & 1)
|
|
614
|
+
if (mouseJoystickEnabled) {
|
|
615
|
+
processor.adconverter.setChannelSource(0, mouseJoystickSource);
|
|
616
|
+
processor.adconverter.setChannelSource(1, mouseJoystickSource);
|
|
617
|
+
mouseJoystickSource.setVia(processor.sysvia);
|
|
618
|
+
} else {
|
|
619
|
+
mouseJoystickSource.setVia(null);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Apply microphone if configured (can override any channel)
|
|
623
|
+
if (microphoneChannel !== undefined) {
|
|
624
|
+
processor.adconverter.setChannelSource(microphoneChannel, microphoneInput);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
async function ensureMicrophoneRunning() {
|
|
629
|
+
if (microphoneInput.audioContext && microphoneInput.audioContext.state !== "running") {
|
|
630
|
+
try {
|
|
631
|
+
await microphoneInput.audioContext.resume();
|
|
632
|
+
console.log("Microphone: Audio context resumed, new state:", microphoneInput.audioContext.state);
|
|
633
|
+
} catch (err) {
|
|
634
|
+
console.error("Microphone: Error resuming audio context:", err);
|
|
635
|
+
return false;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
return true;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async function setupMicrophone() {
|
|
642
|
+
const $micPermissionStatus = $("#micPermissionStatus");
|
|
643
|
+
$micPermissionStatus.text("Requesting microphone access...");
|
|
644
|
+
|
|
645
|
+
// Try to initialise the microphone
|
|
646
|
+
const success = await microphoneInput.initialise();
|
|
647
|
+
if (success) {
|
|
648
|
+
// Note: Channel assignment is handled by updateAdcSources()
|
|
649
|
+
$micPermissionStatus.text("Microphone connected successfully");
|
|
650
|
+
await ensureMicrophoneRunning();
|
|
651
|
+
|
|
652
|
+
// Try starting audio context from user gesture
|
|
653
|
+
const tryAgain = async () => {
|
|
654
|
+
if (await ensureMicrophoneRunning()) document.removeEventListener("click", tryAgain);
|
|
655
|
+
};
|
|
656
|
+
document.addEventListener("click", tryAgain);
|
|
657
|
+
} else {
|
|
658
|
+
$micPermissionStatus.text(`Error: ${microphoneInput.getErrorMessage() || "Unknown error"}`);
|
|
659
|
+
config.setMicrophoneChannel(undefined);
|
|
660
|
+
// Update URL to remove the parameter
|
|
661
|
+
delete parsedQuery.microphoneChannel;
|
|
662
|
+
updateUrl();
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (parsedQuery.microphoneChannel !== undefined) {
|
|
667
|
+
// We need to use setTimeout to make sure this runs after the page has loaded
|
|
668
|
+
// This is needed because some browsers require user interaction for audio context
|
|
669
|
+
setTimeout(async () => {
|
|
670
|
+
await setupMicrophone();
|
|
671
|
+
}, 1000);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Apply ADC source settings from URL parameters
|
|
675
|
+
updateAdcSources(parsedQuery.mouseJoystickEnabled, parsedQuery.microphoneChannel);
|
|
676
|
+
|
|
677
|
+
// Initialise keyboard now that processor exists
|
|
678
|
+
keyboard = new Keyboard({
|
|
679
|
+
processor,
|
|
680
|
+
inputEnabledFunction: () => document.activeElement && document.activeElement.id === "paste-text",
|
|
681
|
+
keyLayout,
|
|
682
|
+
dbgr,
|
|
683
|
+
});
|
|
684
|
+
keyboard.on("showError", ({ context, error }) => showError(context, error));
|
|
685
|
+
keyboard.on("pause", () => stop(false));
|
|
686
|
+
keyboard.on("resume", () => go());
|
|
687
|
+
keyboard.on("break", (pressed) => {
|
|
688
|
+
// F12/Break: Reset processor
|
|
689
|
+
if (pressed) utils.noteEvent("keyboard", "press", "break");
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
// Register default key handlers
|
|
693
|
+
keyboard.registerKeyHandler(
|
|
694
|
+
utils.keyCodes.S,
|
|
695
|
+
(down) => {
|
|
696
|
+
if (down) {
|
|
697
|
+
utils.noteEvent("keyboard", "press", "S");
|
|
698
|
+
stop(true);
|
|
699
|
+
}
|
|
700
|
+
},
|
|
701
|
+
{ alt: true, ctrl: false },
|
|
702
|
+
);
|
|
703
|
+
|
|
704
|
+
keyboard.registerKeyHandler(
|
|
705
|
+
utils.keyCodes.R,
|
|
706
|
+
(down) => {
|
|
707
|
+
if (down) window.location.reload();
|
|
708
|
+
},
|
|
709
|
+
{ alt: true, ctrl: false },
|
|
710
|
+
);
|
|
711
|
+
|
|
712
|
+
// Register Ctrl key handlers
|
|
713
|
+
keyboard.registerKeyHandler(
|
|
714
|
+
utils.keyCodes.HOME,
|
|
715
|
+
(down) => {
|
|
716
|
+
if (down) {
|
|
717
|
+
utils.noteEvent("keyboard", "press", "home");
|
|
718
|
+
stop(true);
|
|
719
|
+
}
|
|
720
|
+
},
|
|
721
|
+
{ alt: false, ctrl: true },
|
|
722
|
+
);
|
|
723
|
+
|
|
724
|
+
keyboard.registerKeyHandler(
|
|
725
|
+
utils.keyCodes.INSERT,
|
|
726
|
+
(down) => {
|
|
727
|
+
if (down) {
|
|
728
|
+
utils.noteEvent("keyboard", "press", "insert");
|
|
729
|
+
fastAsPossible = !fastAsPossible;
|
|
730
|
+
}
|
|
731
|
+
},
|
|
732
|
+
{ alt: false, ctrl: true },
|
|
733
|
+
);
|
|
734
|
+
|
|
735
|
+
keyboard.registerKeyHandler(
|
|
736
|
+
utils.keyCodes.END,
|
|
737
|
+
(down) => {
|
|
738
|
+
if (down) {
|
|
739
|
+
utils.noteEvent("keyboard", "press", "end");
|
|
740
|
+
keyboard.pauseEmulation();
|
|
741
|
+
}
|
|
742
|
+
},
|
|
743
|
+
{ alt: false, ctrl: true },
|
|
744
|
+
);
|
|
745
|
+
|
|
746
|
+
keyboard.registerKeyHandler(
|
|
747
|
+
utils.keyCodes.B,
|
|
748
|
+
(down) => {
|
|
749
|
+
if (down) {
|
|
750
|
+
checkPrinterWindow();
|
|
751
|
+
}
|
|
752
|
+
},
|
|
753
|
+
{ alt: false, ctrl: true },
|
|
754
|
+
);
|
|
755
|
+
|
|
756
|
+
// Setup key handlers
|
|
757
|
+
document.addEventListener("keydown", (evt) => {
|
|
758
|
+
audioHandler.tryResume().then(() => {});
|
|
759
|
+
ensureMicrophoneRunning().then(() => {});
|
|
760
|
+
keyboard.keyDown(evt);
|
|
761
|
+
});
|
|
762
|
+
document.addEventListener("keypress", (evt) => keyboard.keyPress(evt));
|
|
763
|
+
document.addEventListener("keyup", (evt) => keyboard.keyUp(evt));
|
|
764
|
+
|
|
765
|
+
function setDisc1Image(name) {
|
|
766
|
+
delete parsedQuery.disc;
|
|
767
|
+
parsedQuery.disc1 = name;
|
|
768
|
+
updateUrl();
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function sthClearList() {
|
|
772
|
+
$("#sth-list li:not(.template)").remove();
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function sthStartLoad() {
|
|
776
|
+
const $sth = $("#sth .loading");
|
|
777
|
+
$sth.text("Loading catalog from STH archive");
|
|
778
|
+
$sth.show();
|
|
779
|
+
sthClearList();
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
async function discSthClick(item) {
|
|
783
|
+
utils.noteEvent("sth", "click", item);
|
|
784
|
+
setDisc1Image("sth:" + item);
|
|
785
|
+
const needsAutoboot = parsedQuery.autoboot !== undefined;
|
|
786
|
+
if (needsAutoboot) {
|
|
787
|
+
processor.reset(true);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
popupLoading("Loading " + item);
|
|
791
|
+
try {
|
|
792
|
+
const disc = await loadDiscImage(parsedQuery.disc1);
|
|
793
|
+
processor.fdc.loadDisc(0, disc);
|
|
794
|
+
loadingFinished();
|
|
795
|
+
|
|
796
|
+
if (needsAutoboot) {
|
|
797
|
+
autoboot(item);
|
|
798
|
+
}
|
|
799
|
+
} catch (err) {
|
|
800
|
+
console.error("Error loading disc image:", err);
|
|
801
|
+
loadingFinished(err);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
async function tapeSthClick(item) {
|
|
806
|
+
utils.noteEvent("sth", "clickTape", item);
|
|
807
|
+
parsedQuery.tape = "sth:" + item;
|
|
808
|
+
updateUrl();
|
|
809
|
+
|
|
810
|
+
popupLoading("Loading " + item);
|
|
811
|
+
try {
|
|
812
|
+
const tape = await loadTapeImage(parsedQuery.tape);
|
|
813
|
+
processor.acia.setTape(tape);
|
|
814
|
+
loadingFinished();
|
|
815
|
+
} catch (err) {
|
|
816
|
+
console.error("Error loading tape image:", err);
|
|
817
|
+
loadingFinished(err);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const $sthModal = new bootstrap.Modal(document.getElementById("sth"));
|
|
822
|
+
|
|
823
|
+
function makeOnCat(onClick) {
|
|
824
|
+
return function (cat) {
|
|
825
|
+
sthClearList();
|
|
826
|
+
const sthList = $("#sth-list");
|
|
827
|
+
$("#sth .loading").hide();
|
|
828
|
+
const template = sthList.find(".template");
|
|
829
|
+
|
|
830
|
+
function doSome(all) {
|
|
831
|
+
const MaxAtATime = 100;
|
|
832
|
+
const Delay = 30;
|
|
833
|
+
const cat = all.slice(0, MaxAtATime);
|
|
834
|
+
const remaining = all.slice(MaxAtATime);
|
|
835
|
+
const filter = $("#sth-filter").val();
|
|
836
|
+
$.each(cat, function (_, cat) {
|
|
837
|
+
const row = template.clone().removeClass("template").appendTo(sthList);
|
|
838
|
+
row.find(".name").text(cat);
|
|
839
|
+
$(row).on("click", function () {
|
|
840
|
+
onClick(cat);
|
|
841
|
+
$sthModal.hide();
|
|
842
|
+
});
|
|
843
|
+
row.toggle(cat.toLowerCase().indexOf(filter) >= 0);
|
|
844
|
+
});
|
|
845
|
+
if (all.length) _.delay(doSome, Delay, remaining);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
doSome(cat);
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function sthOnError() {
|
|
853
|
+
const $sthLoading = $("#sth .loading");
|
|
854
|
+
$sthLoading.text("There was an error accessing the STH archive");
|
|
855
|
+
$sthLoading.show();
|
|
856
|
+
sthClearList();
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
discSth = new StairwayToHell(sthStartLoad, makeOnCat(discSthClick), sthOnError, false);
|
|
860
|
+
tapeSth = new StairwayToHell(sthStartLoad, makeOnCat(tapeSthClick), sthOnError, true);
|
|
861
|
+
|
|
862
|
+
const $sthAutoboot = $("#sth .autoboot");
|
|
863
|
+
$sthAutoboot.click(function () {
|
|
864
|
+
if ($sthAutoboot.prop("checked")) {
|
|
865
|
+
parsedQuery.autoboot = "";
|
|
866
|
+
} else {
|
|
867
|
+
delete parsedQuery.autoboot;
|
|
868
|
+
}
|
|
869
|
+
updateUrl();
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
$(document).on("click", "a.sth", function () {
|
|
873
|
+
const type = $(this).data("id");
|
|
874
|
+
if (type === "discs") {
|
|
875
|
+
discSth.populate();
|
|
876
|
+
} else if (type === "tapes") {
|
|
877
|
+
tapeSth.populate();
|
|
878
|
+
} else {
|
|
879
|
+
console.log("unknown id", type);
|
|
880
|
+
}
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
function setSthFilter(filter) {
|
|
884
|
+
filter = filter.toLowerCase();
|
|
885
|
+
$("#sth-list li:not(.template)").each(function () {
|
|
886
|
+
const el = $(this);
|
|
887
|
+
el.toggle(el.text().toLowerCase().indexOf(filter) >= 0);
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
$("#sth-filter").on("change keyup", function () {
|
|
892
|
+
setSthFilter($("#sth-filter").val());
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
function sendRawKeyboardToBBC(keysToSend, checkCapsAndShiftLocks) {
|
|
896
|
+
if (keyboard) {
|
|
897
|
+
keyboard.sendRawKeyboardToBBC(keysToSend, checkCapsAndShiftLocks);
|
|
898
|
+
} else {
|
|
899
|
+
console.warn("Tried to send keys before keyboard was initialised");
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function autoboot(image) {
|
|
904
|
+
const BBC = utils.BBC;
|
|
905
|
+
|
|
906
|
+
console.log("Autobooting disc");
|
|
907
|
+
utils.noteEvent("init", "autoboot", image);
|
|
908
|
+
|
|
909
|
+
// Shift-break simulation, hold SHIFT for 1000ms.
|
|
910
|
+
sendRawKeyboardToBBC([BBC.SHIFT, 1000], false);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function autoBootType(keys) {
|
|
914
|
+
console.log("Auto typing '" + keys + "'");
|
|
915
|
+
utils.noteEvent("init", "autochain");
|
|
916
|
+
|
|
917
|
+
const bbcKeys = utils.stringToBBCKeys(keys);
|
|
918
|
+
sendRawKeyboardToBBC([1000].concat(bbcKeys), false);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function autoChainTape() {
|
|
922
|
+
console.log("Auto Chaining Tape");
|
|
923
|
+
utils.noteEvent("init", "autochain");
|
|
924
|
+
|
|
925
|
+
const bbcKeys = utils.stringToBBCKeys('*TAPE\nCH.""\n');
|
|
926
|
+
sendRawKeyboardToBBC([1000].concat(bbcKeys), false);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function autoRunTape() {
|
|
930
|
+
console.log("Auto Running Tape");
|
|
931
|
+
utils.noteEvent("init", "autorun");
|
|
932
|
+
|
|
933
|
+
const bbcKeys = utils.stringToBBCKeys("*TAPE\n*/\n");
|
|
934
|
+
sendRawKeyboardToBBC([1000].concat(bbcKeys), false);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function autoRunBasic() {
|
|
938
|
+
console.log("Auto Running basic");
|
|
939
|
+
utils.noteEvent("init", "autorunbasic");
|
|
940
|
+
|
|
941
|
+
const bbcKeys = utils.stringToBBCKeys("RUN\n");
|
|
942
|
+
sendRawKeyboardToBBC([1000].concat(bbcKeys), false);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function updateUrl() {
|
|
946
|
+
const baseUrl = window.location.origin + window.location.pathname;
|
|
947
|
+
const url = buildUrlFromParams(baseUrl, parsedQuery, paramTypes);
|
|
948
|
+
window.history.pushState(null, null, url);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function splitImage(image) {
|
|
952
|
+
const match = image.match(/(([^:]+):\/?\/?|[!^|])?(.*)/);
|
|
953
|
+
const schema = match[2] || match[1] || "";
|
|
954
|
+
image = match[3];
|
|
955
|
+
return { image: image, schema: schema };
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
async function loadDiscImage(discImage) {
|
|
959
|
+
if (!discImage) return null;
|
|
960
|
+
const split = splitImage(discImage);
|
|
961
|
+
discImage = split.image;
|
|
962
|
+
const schema = split.schema;
|
|
963
|
+
if (schema[0] === "!" || schema === "local") {
|
|
964
|
+
return disc.localDisc(processor.fdc, discImage);
|
|
965
|
+
}
|
|
966
|
+
// TODO: come up with a decent UX for passing an 'onChange' parameter to each of these.
|
|
967
|
+
// Consider:
|
|
968
|
+
// * hashing contents and making a local disc image named by original disc hash, save by that, and offer
|
|
969
|
+
// to load the modified disc on load.
|
|
970
|
+
// * popping up a message that notes the disc has changed, and offers a way to make a local image
|
|
971
|
+
// * Dialog box (ugh) saying "is this ok?"
|
|
972
|
+
switch (schema) {
|
|
973
|
+
case "|":
|
|
974
|
+
case "sth":
|
|
975
|
+
return disc.discFor(processor.fdc, discImage, await discSth.fetch(discImage));
|
|
976
|
+
|
|
977
|
+
case "gd": {
|
|
978
|
+
const splat = discImage.match(/([^/]+)\/?(.*)/);
|
|
979
|
+
let name = "(unknown)";
|
|
980
|
+
if (splat) {
|
|
981
|
+
discImage = splat[1];
|
|
982
|
+
name = splat[2];
|
|
983
|
+
}
|
|
984
|
+
return gdLoad({ name, id: discImage });
|
|
985
|
+
}
|
|
986
|
+
case "b64data":
|
|
987
|
+
return disc.discFor(processor.fdc, "disk.ssd", atob(discImage));
|
|
988
|
+
|
|
989
|
+
case "data": {
|
|
990
|
+
const arr = Array.prototype.map.call(atob(discImage), (x) => x.charCodeAt(0));
|
|
991
|
+
const { name, data } = utils.unzipDiscImage(arr);
|
|
992
|
+
return disc.discFor(processor.fdc, name, data);
|
|
993
|
+
}
|
|
994
|
+
case "http":
|
|
995
|
+
case "https":
|
|
996
|
+
case "file": {
|
|
997
|
+
const asUrl = `${schema}://${discImage}`;
|
|
998
|
+
// url may end in query params etc, which can upset the DSD/SSD etc detection on the extension.
|
|
999
|
+
discImage = new URL(asUrl).pathname;
|
|
1000
|
+
let discData = await utils.loadData(asUrl);
|
|
1001
|
+
if (/\.zip/i.test(discImage)) {
|
|
1002
|
+
const unzipped = utils.unzipDiscImage(discData);
|
|
1003
|
+
discData = unzipped.data;
|
|
1004
|
+
discImage = unzipped.name;
|
|
1005
|
+
}
|
|
1006
|
+
return disc.discFor(processor.fdc, discImage, discData);
|
|
1007
|
+
}
|
|
1008
|
+
default:
|
|
1009
|
+
return disc.discFor(processor.fdc, discImage, await disc.load("discs/" + discImage));
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
async function loadTapeImage(tapeImage) {
|
|
1014
|
+
const split = splitImage(tapeImage);
|
|
1015
|
+
tapeImage = split.image;
|
|
1016
|
+
const schema = split.schema;
|
|
1017
|
+
|
|
1018
|
+
switch (schema) {
|
|
1019
|
+
case "|":
|
|
1020
|
+
case "sth":
|
|
1021
|
+
return loadTapeFromData(tapeImage, await tapeSth.fetch(tapeImage));
|
|
1022
|
+
|
|
1023
|
+
case "data": {
|
|
1024
|
+
const arr = Array.prototype.map.call(atob(tapeImage), (x) => x.charCodeAt(0));
|
|
1025
|
+
const { name, data } = utils.unzipDiscImage(arr);
|
|
1026
|
+
return loadTapeFromData(name, data);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
case "http":
|
|
1030
|
+
case "https": {
|
|
1031
|
+
const asUrl = `${schema}://${tapeImage}`;
|
|
1032
|
+
// url may end in query params etc, which can upset file handling
|
|
1033
|
+
tapeImage = new URL(asUrl).pathname;
|
|
1034
|
+
let tapeData = await utils.loadData(asUrl);
|
|
1035
|
+
if (/\.zip/i.test(tapeImage)) {
|
|
1036
|
+
const unzipped = utils.unzipDiscImage(tapeData);
|
|
1037
|
+
tapeData = unzipped.data;
|
|
1038
|
+
tapeImage = unzipped.name;
|
|
1039
|
+
}
|
|
1040
|
+
return loadTapeFromData(tapeImage, tapeData);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
default:
|
|
1044
|
+
return await loadTape("tapes/" + tapeImage);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
$("#disc_load").on("change", async function (evt) {
|
|
1049
|
+
if (evt.target.files.length === 0) return;
|
|
1050
|
+
utils.noteEvent("local", "click"); // NB no filename here
|
|
1051
|
+
const file = evt.target.files[0];
|
|
1052
|
+
await loadHTMLFile(file);
|
|
1053
|
+
evt.target.value = ""; // clear so if the user picks the same file again after a reset we get a "change"
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
$("#fs_load").on("change", async function (evt) {
|
|
1057
|
+
if (evt.target.files.length === 0) return;
|
|
1058
|
+
utils.noteEvent("local", "click"); // NB no filename here
|
|
1059
|
+
const file = evt.target.files[0];
|
|
1060
|
+
await loadSCSIFile(file);
|
|
1061
|
+
evt.target.value = ""; // clear so if the user picks the same file again after a reset we get a "change"
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
$("#tape_load").on("change", async function (evt) {
|
|
1065
|
+
if (evt.target.files.length === 0) return;
|
|
1066
|
+
const file = evt.target.files[0];
|
|
1067
|
+
utils.noteEvent("local", "clickTape"); // NB no filename here
|
|
1068
|
+
|
|
1069
|
+
const binaryData = await readFileAsBinaryString(file);
|
|
1070
|
+
processor.acia.setTape(loadTapeFromData("local file", binaryData));
|
|
1071
|
+
delete parsedQuery.tape;
|
|
1072
|
+
updateUrl();
|
|
1073
|
+
$("#tapes").modal("hide");
|
|
1074
|
+
|
|
1075
|
+
evt.target.value = ""; // clear so if the user picks the same file again after a reset we get a "change"
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
function anyModalsVisible() {
|
|
1079
|
+
return $(".modal:visible").length !== 0;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
let modalSavedRunning = false;
|
|
1083
|
+
document.addEventListener("show.bs.modal", function () {
|
|
1084
|
+
if (!anyModalsVisible()) modalSavedRunning = running;
|
|
1085
|
+
if (running) stop(false);
|
|
1086
|
+
});
|
|
1087
|
+
document.addEventListener("hidden.bs.modal", function () {
|
|
1088
|
+
if (!anyModalsVisible() && modalSavedRunning) {
|
|
1089
|
+
go();
|
|
1090
|
+
}
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
const $loadingDialog = $("#loading-dialog");
|
|
1094
|
+
const $loadingDialogModal = new bootstrap.Modal($loadingDialog[0]);
|
|
1095
|
+
|
|
1096
|
+
function popupLoading(msg) {
|
|
1097
|
+
$loadingDialog.find(".loading").text(msg);
|
|
1098
|
+
$("#google-drive-auth").hide();
|
|
1099
|
+
$loadingDialogModal.show();
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function loadingFinished(error) {
|
|
1103
|
+
$("#google-drive-auth").hide();
|
|
1104
|
+
if (error) {
|
|
1105
|
+
$loadingDialogModal.show();
|
|
1106
|
+
$loadingDialog.find(".loading").text("Error: " + error);
|
|
1107
|
+
setTimeout(function () {
|
|
1108
|
+
$loadingDialogModal.hide();
|
|
1109
|
+
}, 5000);
|
|
1110
|
+
} else {
|
|
1111
|
+
$loadingDialogModal.hide();
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
const googleDrive = new GoogleDriveLoader();
|
|
1116
|
+
|
|
1117
|
+
async function gdAuth(imm) {
|
|
1118
|
+
try {
|
|
1119
|
+
return await googleDrive.authorize(imm);
|
|
1120
|
+
} catch (err) {
|
|
1121
|
+
console.log("Error handling google auth: " + err);
|
|
1122
|
+
$googleDrive.find(".loading").text("There was an error accessing your Google Drive account: " + err);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
let googleDriveLoadingResolve, googleDriveLoadingReject;
|
|
1127
|
+
$("#google-drive-auth form").on("submit", async function (e) {
|
|
1128
|
+
$("#google-drive-auth").hide();
|
|
1129
|
+
e.preventDefault();
|
|
1130
|
+
const authed = await gdAuth(false);
|
|
1131
|
+
if (authed) googleDriveLoadingResolve();
|
|
1132
|
+
else googleDriveLoadingReject(new Error("Unable to authorize Google Drive"));
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
async function gdLoad(cat) {
|
|
1136
|
+
// TODO: have a onclose flush event, handle errors
|
|
1137
|
+
/*
|
|
1138
|
+
$(window).bind("beforeunload", function() {
|
|
1139
|
+
return confirm("Do you really want to close?");
|
|
1140
|
+
});
|
|
1141
|
+
*/
|
|
1142
|
+
popupLoading("Loading '" + cat.name + "' from Google Drive");
|
|
1143
|
+
try {
|
|
1144
|
+
const available = await googleDrive.initialise();
|
|
1145
|
+
console.log("Google Drive available =", available);
|
|
1146
|
+
if (!available) throw new Error("Google Drive is not available");
|
|
1147
|
+
|
|
1148
|
+
const authed = await gdAuth(true);
|
|
1149
|
+
console.log("Google Drive authed=", authed);
|
|
1150
|
+
|
|
1151
|
+
if (!authed) {
|
|
1152
|
+
await new Promise(function (resolve, reject) {
|
|
1153
|
+
googleDriveLoadingResolve = resolve;
|
|
1154
|
+
googleDriveLoadingReject = reject;
|
|
1155
|
+
$("#google-drive-auth").show();
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
const ssd = await googleDrive.load(processor.fdc, cat.id);
|
|
1160
|
+
console.log("Google Drive loading finished");
|
|
1161
|
+
loadingFinished();
|
|
1162
|
+
return ssd;
|
|
1163
|
+
} catch (error) {
|
|
1164
|
+
console.error("Google Drive loading error:", error);
|
|
1165
|
+
loadingFinished(error);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
$(".if-drive-available").hide();
|
|
1170
|
+
(async () => {
|
|
1171
|
+
const available = await googleDrive.initialise();
|
|
1172
|
+
if (available) {
|
|
1173
|
+
$(".if-drive-available").show();
|
|
1174
|
+
await gdAuth(true);
|
|
1175
|
+
}
|
|
1176
|
+
})();
|
|
1177
|
+
const $googleDrive = $("#google-drive");
|
|
1178
|
+
const $googleDriveModal = new bootstrap.Modal($googleDrive[0]);
|
|
1179
|
+
$("#open-drive-link").on("click", async function () {
|
|
1180
|
+
const authed = await gdAuth(false);
|
|
1181
|
+
if (authed) {
|
|
1182
|
+
$googleDriveModal.show();
|
|
1183
|
+
}
|
|
1184
|
+
return false;
|
|
1185
|
+
});
|
|
1186
|
+
$googleDrive[0].addEventListener("show.bs.modal", async function () {
|
|
1187
|
+
$googleDrive.find(".loading").text("Loading...").show();
|
|
1188
|
+
$googleDrive.find("li").not(".template").remove();
|
|
1189
|
+
const cat = await googleDrive.listFiles();
|
|
1190
|
+
const dbList = $googleDrive.find(".list");
|
|
1191
|
+
$googleDrive.find(".loading").hide();
|
|
1192
|
+
const template = dbList.find(".template");
|
|
1193
|
+
$.each(cat, function (_, cat) {
|
|
1194
|
+
const row = template.clone().removeClass("template").appendTo(dbList);
|
|
1195
|
+
row.find(".name").text(cat.name);
|
|
1196
|
+
$(row).on("click", function () {
|
|
1197
|
+
utils.noteEvent("google-drive", "click", cat.name);
|
|
1198
|
+
setDisc1Image(`gd:${cat.id}/${cat.name}`);
|
|
1199
|
+
gdLoad(cat).then(function (ssd) {
|
|
1200
|
+
processor.fdc.loadDisc(0, ssd);
|
|
1201
|
+
});
|
|
1202
|
+
$googleDriveModal.hide();
|
|
1203
|
+
});
|
|
1204
|
+
});
|
|
1205
|
+
});
|
|
1206
|
+
const discList = $("#disc-list");
|
|
1207
|
+
const template = discList.find(".template");
|
|
1208
|
+
$.each(availableImages, function (i, image) {
|
|
1209
|
+
const elem = template.clone().removeClass("template").appendTo(discList);
|
|
1210
|
+
elem.find(".name").text(image.name);
|
|
1211
|
+
elem.find(".description").text(image.desc);
|
|
1212
|
+
$(elem).on("click", function () {
|
|
1213
|
+
utils.noteEvent("images", "click", image.file);
|
|
1214
|
+
setDisc1Image(image.file);
|
|
1215
|
+
loadDiscImage(parsedQuery.disc1).then(function (disc) {
|
|
1216
|
+
processor.fdc.loadDisc(0, disc);
|
|
1217
|
+
});
|
|
1218
|
+
$discsModal.hide();
|
|
1219
|
+
});
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
$("#google-drive form").on("submit", async function (e) {
|
|
1223
|
+
e.preventDefault();
|
|
1224
|
+
let name = $("#google-drive .disc-name").val();
|
|
1225
|
+
if (!name) return;
|
|
1226
|
+
|
|
1227
|
+
popupLoading("Connecting to Google Drive");
|
|
1228
|
+
$googleDriveModal.hide();
|
|
1229
|
+
popupLoading("Creating '" + name + "' on Google Drive");
|
|
1230
|
+
|
|
1231
|
+
let data;
|
|
1232
|
+
if ($("#google-drive .create-from-existing").prop("checked")) {
|
|
1233
|
+
const discType = disc.guessDiscTypeFromName(name);
|
|
1234
|
+
data = discType.saver(processor.fdc.drives[0].disc);
|
|
1235
|
+
name = replaceOrAddExtension(name, discType.extension);
|
|
1236
|
+
console.log(`Saving existing disc: ${name}`);
|
|
1237
|
+
} else {
|
|
1238
|
+
// TODO support HFE, I guess?
|
|
1239
|
+
const discType = disc.guessDiscTypeFromName(name);
|
|
1240
|
+
if (!discType.byteSize) {
|
|
1241
|
+
throw new Error(`Cannot create blank disc of type ${discType.extension} - unknown size`);
|
|
1242
|
+
}
|
|
1243
|
+
data = new Uint8Array(discType.byteSize);
|
|
1244
|
+
if (discType.supportsCatalogue) {
|
|
1245
|
+
discType.setDiscName(data, name);
|
|
1246
|
+
}
|
|
1247
|
+
console.log(`Creating blank: ${name}`);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
try {
|
|
1251
|
+
const result = await googleDrive.create(processor.fdc, name, data);
|
|
1252
|
+
setDisc1Image("gd:" + result.fileId + "/" + name);
|
|
1253
|
+
processor.fdc.loadDisc(0, result.disc);
|
|
1254
|
+
loadingFinished();
|
|
1255
|
+
} catch (error) {
|
|
1256
|
+
console.error(`Error creating Google Drive disc: ${error}`, error);
|
|
1257
|
+
loadingFinished(`Create failed: ${error}`);
|
|
1258
|
+
}
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
$("#download-drive-link").on("click", function () {
|
|
1262
|
+
const disc = processor.fdc.drives[0].disc;
|
|
1263
|
+
const data = toSsdOrDsd(disc);
|
|
1264
|
+
const name = disc.name;
|
|
1265
|
+
const extension = disc.isDoubleSided ? ".dsd" : ".ssd";
|
|
1266
|
+
|
|
1267
|
+
downloadDriveData(data, name, extension);
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
$("#download-drive-hfe-link").on("click", function () {
|
|
1271
|
+
const disc = processor.fdc.drives[0].disc;
|
|
1272
|
+
const data = toHfe(disc);
|
|
1273
|
+
const name = disc.name;
|
|
1274
|
+
|
|
1275
|
+
downloadDriveData(data, name, ".hfe");
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
$("#download-filestore-link").on("click", function () {
|
|
1279
|
+
downloadDriveData(processor.filestore.scsi, "scsi", ".dat");
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
$("#hard-reset").click(function (event) {
|
|
1283
|
+
processor.reset(true);
|
|
1284
|
+
event.preventDefault();
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
$("#soft-reset").click(function (event) {
|
|
1288
|
+
processor.reset(false);
|
|
1289
|
+
event.preventDefault();
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1292
|
+
$("#tape-menu a").on("click", function (e) {
|
|
1293
|
+
const type = $(e.target).attr("data-id");
|
|
1294
|
+
if (type === undefined) return;
|
|
1295
|
+
|
|
1296
|
+
if (type === "rewind") {
|
|
1297
|
+
console.log("Rewinding tape to the start");
|
|
1298
|
+
|
|
1299
|
+
processor.acia.rewindTape();
|
|
1300
|
+
} else {
|
|
1301
|
+
console.log("unknown type", type);
|
|
1302
|
+
}
|
|
1303
|
+
});
|
|
1304
|
+
|
|
1305
|
+
function Light(name) {
|
|
1306
|
+
const dom = $("#" + name);
|
|
1307
|
+
let on = false;
|
|
1308
|
+
this.update = function (val) {
|
|
1309
|
+
if (val === on) return;
|
|
1310
|
+
on = val;
|
|
1311
|
+
dom.toggleClass("on", on);
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
const cassette = new Light("motorlight");
|
|
1316
|
+
const caps = new Light("capslight");
|
|
1317
|
+
const shift = new Light("shiftlight");
|
|
1318
|
+
const drive0 = new Light("drive0");
|
|
1319
|
+
const drive1 = new Light("drive1");
|
|
1320
|
+
const network = new Light("networklight");
|
|
1321
|
+
|
|
1322
|
+
syncLights = function () {
|
|
1323
|
+
caps.update(processor.sysvia.capsLockLight);
|
|
1324
|
+
shift.update(processor.sysvia.shiftLockLight);
|
|
1325
|
+
drive0.update(processor.fdc.motorOn[0]);
|
|
1326
|
+
drive1.update(processor.fdc.motorOn[1]);
|
|
1327
|
+
cassette.update(processor.acia.motorOn);
|
|
1328
|
+
if (model.hasEconet) {
|
|
1329
|
+
network.update(processor.econet.activityLight());
|
|
1330
|
+
}
|
|
1331
|
+
};
|
|
1332
|
+
|
|
1333
|
+
const startPromise = (async () => {
|
|
1334
|
+
await Promise.all([audioHandler.initialise(), processor.initialise()]);
|
|
1335
|
+
|
|
1336
|
+
// Ideally would start the loads first. But their completion needs the FDC from the processor
|
|
1337
|
+
const imageLoads = [];
|
|
1338
|
+
|
|
1339
|
+
if (discImage) {
|
|
1340
|
+
imageLoads.push(
|
|
1341
|
+
(async () => {
|
|
1342
|
+
const disc = await loadDiscImage(discImage);
|
|
1343
|
+
processor.fdc.loadDisc(0, disc);
|
|
1344
|
+
})(),
|
|
1345
|
+
);
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
if (secondDiscImage) {
|
|
1349
|
+
imageLoads.push(
|
|
1350
|
+
(async () => {
|
|
1351
|
+
const disc = await loadDiscImage(secondDiscImage);
|
|
1352
|
+
processor.fdc.loadDisc(1, disc);
|
|
1353
|
+
})(),
|
|
1354
|
+
);
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
if (parsedQuery.tape) {
|
|
1358
|
+
imageLoads.push(
|
|
1359
|
+
(async () => {
|
|
1360
|
+
const tape = await loadTapeImage(parsedQuery.tape);
|
|
1361
|
+
processor.acia.setTape(tape);
|
|
1362
|
+
})(),
|
|
1363
|
+
);
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
async function insertBasic(getBasicPromise, needsRun) {
|
|
1367
|
+
const basicLoadPromise = (async () => {
|
|
1368
|
+
const prog = await getBasicPromise;
|
|
1369
|
+
const t = await tokeniser.create();
|
|
1370
|
+
const tokenised = await t.tokenise(prog);
|
|
1371
|
+
|
|
1372
|
+
const idleAddr = processor.model.isMaster ? 0xe7e6 : 0xe581;
|
|
1373
|
+
const hook = processor.debugInstruction.add(function (addr) {
|
|
1374
|
+
if (addr !== idleAddr) return;
|
|
1375
|
+
const page = processor.readmem(0x18) << 8;
|
|
1376
|
+
for (let i = 0; i < tokenised.length; ++i) {
|
|
1377
|
+
processor.writemem(page + i, tokenised.charCodeAt(i));
|
|
1378
|
+
}
|
|
1379
|
+
// Set VARTOP (0x12/3) and TOP(0x02/3)
|
|
1380
|
+
const end = page + tokenised.length;
|
|
1381
|
+
const endLow = end & 0xff;
|
|
1382
|
+
const endHigh = (end >>> 8) & 0xff;
|
|
1383
|
+
processor.writemem(0x02, endLow);
|
|
1384
|
+
processor.writemem(0x03, endHigh);
|
|
1385
|
+
processor.writemem(0x12, endLow);
|
|
1386
|
+
processor.writemem(0x13, endHigh);
|
|
1387
|
+
hook.remove();
|
|
1388
|
+
if (needsRun) {
|
|
1389
|
+
autoRunBasic();
|
|
1390
|
+
}
|
|
1391
|
+
});
|
|
1392
|
+
return tokenised; // Explicitly return the result
|
|
1393
|
+
})();
|
|
1394
|
+
|
|
1395
|
+
imageLoads.push(basicLoadPromise);
|
|
1396
|
+
return basicLoadPromise; // Return promise for caller to await if needed
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
if (parsedQuery.loadBasic) {
|
|
1400
|
+
const needsRun = needsAutoboot === "run";
|
|
1401
|
+
needsAutoboot = "";
|
|
1402
|
+
|
|
1403
|
+
await insertBasic(
|
|
1404
|
+
(async () => {
|
|
1405
|
+
const data = await utils.loadData(parsedQuery.loadBasic);
|
|
1406
|
+
return String.fromCharCode.apply(null, data);
|
|
1407
|
+
})(),
|
|
1408
|
+
needsRun,
|
|
1409
|
+
);
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
if (parsedQuery.embedBasic) {
|
|
1413
|
+
await insertBasic(Promise.resolve(parsedQuery.embedBasic), true);
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
return Promise.all(imageLoads);
|
|
1417
|
+
})();
|
|
1418
|
+
|
|
1419
|
+
startPromise
|
|
1420
|
+
.then(() => {
|
|
1421
|
+
switch (needsAutoboot) {
|
|
1422
|
+
case "boot":
|
|
1423
|
+
$sthAutoboot.prop("checked", true);
|
|
1424
|
+
autoboot(discImage);
|
|
1425
|
+
break;
|
|
1426
|
+
case "type":
|
|
1427
|
+
autoBootType(autoType);
|
|
1428
|
+
break;
|
|
1429
|
+
case "chain":
|
|
1430
|
+
autoChainTape();
|
|
1431
|
+
break;
|
|
1432
|
+
case "run":
|
|
1433
|
+
autoRunTape();
|
|
1434
|
+
break;
|
|
1435
|
+
default:
|
|
1436
|
+
$sthAutoboot.prop("checked", false);
|
|
1437
|
+
break;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
if (parsedQuery.patch) {
|
|
1441
|
+
dbgr.setPatch(parsedQuery.patch);
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
go();
|
|
1445
|
+
})
|
|
1446
|
+
.catch((error) => {
|
|
1447
|
+
console.error("Error initialising emulator:", error);
|
|
1448
|
+
showError("initialising", error);
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
const $ays = $("#are-you-sure");
|
|
1452
|
+
const $aysModal = new bootstrap.Modal($ays[0]);
|
|
1453
|
+
|
|
1454
|
+
function areYouSure(message, yesText, noText, yesFunc) {
|
|
1455
|
+
$ays.find(".context").text(message);
|
|
1456
|
+
$ays.find(".ays-yes").text(yesText);
|
|
1457
|
+
$ays.find(".ays-no").text(noText);
|
|
1458
|
+
$ays.find(".ays-yes").one("click", function () {
|
|
1459
|
+
$aysModal.hide();
|
|
1460
|
+
yesFunc();
|
|
1461
|
+
});
|
|
1462
|
+
$aysModal.show();
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
function benchmarkCpu(numCycles) {
|
|
1466
|
+
numCycles = numCycles || 10 * 1000 * 1000;
|
|
1467
|
+
const oldFS = frameSkip;
|
|
1468
|
+
frameSkip = 1000000;
|
|
1469
|
+
const startTime = performance.now();
|
|
1470
|
+
processor.execute(numCycles);
|
|
1471
|
+
const endTime = performance.now();
|
|
1472
|
+
frameSkip = oldFS;
|
|
1473
|
+
const msTaken = endTime - startTime;
|
|
1474
|
+
const virtualMhz = numCycles / msTaken / 1000;
|
|
1475
|
+
console.log("Took " + msTaken + "ms to execute " + numCycles + " cycles");
|
|
1476
|
+
console.log("Virtual " + virtualMhz.toFixed(2) + "MHz");
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
function benchmarkVideo(numCycles) {
|
|
1480
|
+
numCycles = numCycles || 10 * 1000 * 1000;
|
|
1481
|
+
const oldFS = frameSkip;
|
|
1482
|
+
frameSkip = 1000000;
|
|
1483
|
+
const startTime = performance.now();
|
|
1484
|
+
video.polltime(numCycles);
|
|
1485
|
+
const endTime = performance.now();
|
|
1486
|
+
frameSkip = oldFS;
|
|
1487
|
+
const msTaken = endTime - startTime;
|
|
1488
|
+
const virtualMhz = numCycles / msTaken / 1000;
|
|
1489
|
+
console.log("Took " + msTaken + "ms to execute " + numCycles + " video cycles");
|
|
1490
|
+
console.log("Virtual " + virtualMhz.toFixed(2) + "MHz");
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
function profileCpu(arg) {
|
|
1494
|
+
console.profile("CPU");
|
|
1495
|
+
benchmarkCpu(arg);
|
|
1496
|
+
console.profileEnd();
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
function profileVideo(arg) {
|
|
1500
|
+
console.profile("Video");
|
|
1501
|
+
benchmarkVideo(arg);
|
|
1502
|
+
console.profileEnd();
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
let last = 0;
|
|
1506
|
+
|
|
1507
|
+
function VirtualSpeedUpdater() {
|
|
1508
|
+
this.cycles = 0;
|
|
1509
|
+
this.time = 0;
|
|
1510
|
+
this.v = $(".virtualMHz");
|
|
1511
|
+
this.header = $("#virtual-mhz-header");
|
|
1512
|
+
this.speedy = false;
|
|
1513
|
+
|
|
1514
|
+
this.update = function (cycles, time, speedy) {
|
|
1515
|
+
this.cycles += cycles;
|
|
1516
|
+
this.time += time;
|
|
1517
|
+
this.speedy = speedy;
|
|
1518
|
+
};
|
|
1519
|
+
|
|
1520
|
+
this.display = function () {
|
|
1521
|
+
// MRG would be nice to graph instantaneous speed to get some idea where the time goes.
|
|
1522
|
+
if (this.cycles) {
|
|
1523
|
+
const thisMHz = this.cycles / this.time / 1000;
|
|
1524
|
+
this.v.text(thisMHz.toFixed(1));
|
|
1525
|
+
if (this.cycles >= 10 * 2 * 1000 * 1000) {
|
|
1526
|
+
this.cycles = this.time = 0;
|
|
1527
|
+
}
|
|
1528
|
+
this.header.css("color", this.speedy ? "red" : "white");
|
|
1529
|
+
}
|
|
1530
|
+
setTimeout(this.display.bind(this), 3333);
|
|
1531
|
+
};
|
|
1532
|
+
|
|
1533
|
+
this.display();
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
const virtualSpeedUpdater = new VirtualSpeedUpdater();
|
|
1537
|
+
|
|
1538
|
+
function draw(now) {
|
|
1539
|
+
if (!running) {
|
|
1540
|
+
last = 0;
|
|
1541
|
+
return;
|
|
1542
|
+
}
|
|
1543
|
+
// If we got here via setTimeout, we don't get passed the time.
|
|
1544
|
+
if (now === undefined) {
|
|
1545
|
+
now = window.performance.now();
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
const motorOn = processor.acia.motorOn;
|
|
1549
|
+
const discOn = processor.fdc.motorOn[0] || processor.fdc.motorOn[1];
|
|
1550
|
+
const speedy = fastAsPossible || (fastTape && motorOn);
|
|
1551
|
+
const useTimeout = speedy || motorOn || discOn;
|
|
1552
|
+
const timeout = speedy ? 0 : 1000.0 / 50;
|
|
1553
|
+
|
|
1554
|
+
// In speedy mode, we still run all the state machines accurately
|
|
1555
|
+
// but we paint less often because painting is the most expensive
|
|
1556
|
+
// part of jsbeeb at this time.
|
|
1557
|
+
// We need need to paint per odd number of frames so that interlace
|
|
1558
|
+
// modes, i.e. MODE 7, still look ok.
|
|
1559
|
+
video.frameSkipCount = speedy ? 9 : 0;
|
|
1560
|
+
|
|
1561
|
+
// We use setTimeout instead of requestAnimationFrame in two cases:
|
|
1562
|
+
// a) We're trying to run as fast as possible.
|
|
1563
|
+
// b) Tape is playing, normal speed but backgrounded tab should run.
|
|
1564
|
+
if (useTimeout) {
|
|
1565
|
+
window.setTimeout(draw, timeout);
|
|
1566
|
+
} else {
|
|
1567
|
+
window.requestAnimationFrame(draw);
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
audioHandler.soundChip.catchUp();
|
|
1571
|
+
gamepad.update(processor.sysvia);
|
|
1572
|
+
syncLights();
|
|
1573
|
+
if (last !== 0) {
|
|
1574
|
+
let cycles;
|
|
1575
|
+
if (!speedy) {
|
|
1576
|
+
// Now and last are DOMHighResTimeStamp, just a double.
|
|
1577
|
+
const sinceLast = now - last;
|
|
1578
|
+
cycles = (sinceLast * clocksPerSecond) / 1000;
|
|
1579
|
+
cycles = Math.min(cycles, MaxCyclesPerFrame);
|
|
1580
|
+
} else {
|
|
1581
|
+
cycles = clocksPerSecond / 50;
|
|
1582
|
+
}
|
|
1583
|
+
cycles |= 0;
|
|
1584
|
+
try {
|
|
1585
|
+
if (!processor.execute(cycles)) {
|
|
1586
|
+
stop(true);
|
|
1587
|
+
}
|
|
1588
|
+
const end = performance.now();
|
|
1589
|
+
virtualSpeedUpdater.update(cycles, end - now, speedy);
|
|
1590
|
+
} catch (e) {
|
|
1591
|
+
running = false;
|
|
1592
|
+
utils.noteEvent("exception", "thrown", e.stack);
|
|
1593
|
+
dbgr.debug(processor.pc);
|
|
1594
|
+
throw e;
|
|
1595
|
+
}
|
|
1596
|
+
if (keyboard.postFrameShouldPause()) {
|
|
1597
|
+
stop(false);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
last = now;
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
function run() {
|
|
1604
|
+
window.requestAnimationFrame(draw);
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
let wasPreviouslyRunning = false;
|
|
1608
|
+
|
|
1609
|
+
function handleVisibilityChange() {
|
|
1610
|
+
if (document.visibilityState === "hidden") {
|
|
1611
|
+
wasPreviouslyRunning = running;
|
|
1612
|
+
const keepRunningWhenHidden = processor.acia.motorOn || processor.fdc.motorOn[0] || processor.fdc.motorOn[1];
|
|
1613
|
+
if (running && !keepRunningWhenHidden) {
|
|
1614
|
+
stop(false);
|
|
1615
|
+
}
|
|
1616
|
+
} else {
|
|
1617
|
+
if (wasPreviouslyRunning) {
|
|
1618
|
+
go();
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
document.addEventListener("visibilitychange", handleVisibilityChange, false);
|
|
1624
|
+
|
|
1625
|
+
function updateDebugButtons() {
|
|
1626
|
+
$debugPlay.attr("disabled", running);
|
|
1627
|
+
$debugPause.attr("disabled", !running);
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
function go() {
|
|
1631
|
+
audioHandler.unmute();
|
|
1632
|
+
running = true;
|
|
1633
|
+
keyboard.setRunning(true);
|
|
1634
|
+
updateDebugButtons();
|
|
1635
|
+
run();
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
function stop(debug) {
|
|
1639
|
+
running = false;
|
|
1640
|
+
keyboard.setRunning(false);
|
|
1641
|
+
processor.stop();
|
|
1642
|
+
if (debug) dbgr.debug(processor.pc);
|
|
1643
|
+
audioHandler.mute();
|
|
1644
|
+
updateDebugButtons();
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
(function () {
|
|
1648
|
+
const $cubMonitor = $("#cub-monitor");
|
|
1649
|
+
const $cubMonitorPic = $("#cub-monitor-pic");
|
|
1650
|
+
const borderReservedSize = parsedQuery.embed !== undefined ? 0 : 100;
|
|
1651
|
+
const bottomReservedSize = parsedQuery.embed !== undefined ? 0 : 68;
|
|
1652
|
+
|
|
1653
|
+
function resizeTv() {
|
|
1654
|
+
// Get current display config (may change when display mode switches)
|
|
1655
|
+
const displayConfig = displayModeFilter.getDisplayConfig();
|
|
1656
|
+
|
|
1657
|
+
const imageOrigHeight = displayConfig.imageHeight;
|
|
1658
|
+
const imageOrigWidth = displayConfig.imageWidth;
|
|
1659
|
+
const canvasOrigLeft = displayConfig.canvasLeft;
|
|
1660
|
+
const canvasOrigTop = displayConfig.canvasTop;
|
|
1661
|
+
const visibleWidth = displayConfig.visibleWidth;
|
|
1662
|
+
const visibleHeight = displayConfig.visibleHeight;
|
|
1663
|
+
|
|
1664
|
+
const canvasNativeWidth = $screen.attr("width");
|
|
1665
|
+
const canvasNativeHeight = $screen.attr("height");
|
|
1666
|
+
const desiredAspectRatio = imageOrigWidth / imageOrigHeight;
|
|
1667
|
+
const minWidth = imageOrigWidth / 4;
|
|
1668
|
+
const minHeight = imageOrigHeight / 4;
|
|
1669
|
+
|
|
1670
|
+
let navbarHeight = $("#header-bar").outerHeight() || 0;
|
|
1671
|
+
let width = Math.max(minWidth, window.innerWidth - borderReservedSize * 2);
|
|
1672
|
+
let height = Math.max(minHeight, window.innerHeight - navbarHeight - bottomReservedSize);
|
|
1673
|
+
if (width / height <= desiredAspectRatio) {
|
|
1674
|
+
height = width / desiredAspectRatio;
|
|
1675
|
+
} else {
|
|
1676
|
+
width = height * desiredAspectRatio;
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
const containerScale = width / imageOrigWidth;
|
|
1680
|
+
const scaledVisibleWidth = visibleWidth * containerScale;
|
|
1681
|
+
const scaledVisibleHeight = visibleHeight * containerScale;
|
|
1682
|
+
|
|
1683
|
+
const canvasAspect = canvasNativeWidth / canvasNativeHeight;
|
|
1684
|
+
const visibleAspect = scaledVisibleWidth / scaledVisibleHeight;
|
|
1685
|
+
|
|
1686
|
+
let finalCanvasWidth, finalCanvasHeight;
|
|
1687
|
+
if (canvasAspect > visibleAspect) {
|
|
1688
|
+
finalCanvasWidth = scaledVisibleWidth;
|
|
1689
|
+
finalCanvasHeight = scaledVisibleWidth / canvasAspect;
|
|
1690
|
+
} else {
|
|
1691
|
+
finalCanvasHeight = scaledVisibleHeight;
|
|
1692
|
+
finalCanvasWidth = scaledVisibleHeight * canvasAspect;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
$cubMonitor.height(height).width(width);
|
|
1696
|
+
$cubMonitorPic.height(height).width(width);
|
|
1697
|
+
$screen.width(finalCanvasWidth).height(finalCanvasHeight);
|
|
1698
|
+
$screen.css("left", canvasOrigLeft * containerScale + "px");
|
|
1699
|
+
$screen.css("top", canvasOrigTop * containerScale + "px");
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
window.addEventListener("resize", resizeTv);
|
|
1703
|
+
window.setTimeout(resizeTv, 1);
|
|
1704
|
+
window.setTimeout(resizeTv, 500);
|
|
1705
|
+
})();
|
|
1706
|
+
|
|
1707
|
+
const $infoModal = new bootstrap.Modal(document.getElementById("info"));
|
|
1708
|
+
const $ppTosModal = new bootstrap.Modal(document.getElementById("pp-tos"));
|
|
1709
|
+
|
|
1710
|
+
if (Object.hasOwn(parsedQuery, "about")) {
|
|
1711
|
+
$infoModal.show();
|
|
1712
|
+
}
|
|
1713
|
+
if (Object.hasOwn(parsedQuery, "pp-tos")) {
|
|
1714
|
+
$ppTosModal.show();
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// Handy shortcuts. bench/profile stuff is delayed so that they can be
|
|
1718
|
+
// safely run from the JS console in firefox.
|
|
1719
|
+
window.benchmarkCpu = _.debounce(benchmarkCpu, 1);
|
|
1720
|
+
window.profileCpu = _.debounce(profileCpu, 1);
|
|
1721
|
+
window.benchmarkVideo = _.debounce(benchmarkVideo, 1);
|
|
1722
|
+
window.profileVideo = _.debounce(profileVideo, 1);
|
|
1723
|
+
window.go = go;
|
|
1724
|
+
window.stop = stop;
|
|
1725
|
+
window.soundChip = audioHandler.soundChip;
|
|
1726
|
+
window.processor = processor;
|
|
1727
|
+
window.video = video;
|
|
1728
|
+
window.hd = function (start, end) {
|
|
1729
|
+
console.log(
|
|
1730
|
+
utils.hd(
|
|
1731
|
+
function (x) {
|
|
1732
|
+
return processor.readmem(x);
|
|
1733
|
+
},
|
|
1734
|
+
start,
|
|
1735
|
+
end,
|
|
1736
|
+
),
|
|
1737
|
+
);
|
|
1738
|
+
};
|
|
1739
|
+
window.m7dump = function () {
|
|
1740
|
+
console.log(
|
|
1741
|
+
utils.hd(
|
|
1742
|
+
function (x) {
|
|
1743
|
+
return processor.readmem(x) & 0x7f;
|
|
1744
|
+
},
|
|
1745
|
+
0x7c00,
|
|
1746
|
+
0x7fe8,
|
|
1747
|
+
{ width: 40, gap: false },
|
|
1748
|
+
),
|
|
1749
|
+
);
|
|
1750
|
+
};
|
|
1751
|
+
|
|
1752
|
+
// Hooks for electron.
|
|
1753
|
+
electron({ loadDiscImage, processor });
|
|
1754
|
+
|
|
1755
|
+
// Display version in About dialog
|
|
1756
|
+
const versionElement = document.getElementById("jsbeeb-version");
|
|
1757
|
+
if (versionElement) {
|
|
1758
|
+
versionElement.textContent = `Version ${version}`;
|
|
1759
|
+
}
|