-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcocomax-online.html
More file actions
2256 lines (2152 loc) · 121 KB
/
Copy pathcocomax-online.html
File metadata and controls
2256 lines (2152 loc) · 121 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CoCo Max Online</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Pixelify+Sans:wght@400..700&family=Doto:wght@100..900&family=Tiny5&family=Micro+5&family=Jacquarda+Bastarda+9&family=Bytesized&display=swap" rel="stylesheet">
<style>
/* ============================================================
CoCo Max Online
A PMODE-4 artifact-color paint program in the MacPaint idiom.
- Source of truth per layer: a 256x192 1bpp PMODE 4 page.
- Color emerges from pixel parity via the artifact shader.
- Floating windows hover over an artifact-stripe desktop.
============================================================ */
:root{
--ink:#000; --paper:#fff;
--orange:#e8801c; --blue:#2b6fe0;
--chrome:#fff; --frame:#000;
--winshadow: 3px 3px 0 rgba(0,0,0,.45);
--mac-font:'ChicagoFLF','Chicago','Charcoal','Geneva','Tahoma',Verdana,sans-serif;
}
*{box-sizing:border-box;}
html,body{margin:0;height:100%;overflow:hidden;}
body{
font-family:var(--mac-font); font-weight:700; color:#000;
user-select:none;-webkit-user-select:none;
background:#888;
}
/* ---------- artifact-stripe desktop ---------- */
/* Horizontal scanlines of orange/blue over black. Rendered as a pixel-exact
6px-tall tile (set from JS as a data-URL) so there is no sub-pixel rounding
or moire. No blend overlay — that caused diagonal interference seams. */
#desktop{
position:fixed; inset:0; z-index:0;
background-color:#000;
background-repeat:repeat;
image-rendering:pixelated;
}
/* ---------- top menu bar (fixed, MacPaint style) ---------- */
#menubar{
position:fixed;top:0;left:0;right:0;height:24px;z-index:1000;
background:#fff;border-bottom:2px solid #000;
display:flex;align-items:stretch;padding:0 4px;
font-size:15px;letter-spacing:.3px;
}
.menu{padding:2px 12px 0;cursor:default;position:relative;display:flex;align-items:center;line-height:1;}
.menu:hover,.menu.open{background:#000;color:#fff;}
.dropdown{
position:absolute;top:100%;left:0;min-width:210px;background:#fff;color:#000;
border:2px solid #000;box-shadow:var(--winshadow);
z-index:1100;display:none;padding:3px 0;font-size:14px;
}
.menu.open .dropdown{display:block;}
.mi{padding:2px 18px 2px 26px;cursor:default;white-space:nowrap;position:relative;
display:flex;justify-content:space-between;gap:26px;}
.mi:hover:not(.disabled){background:#000;color:#fff;}
.mi.disabled{color:#aaa;}
.mi .sc{opacity:.8;font-weight:700;}
.mi.check::before{content:"✓";position:absolute;left:9px;}
.mi.nocheck::before{content:"";}
.mi.sep{padding:0;margin:4px 10px;border-top:1px dotted #000;height:0;pointer-events:none;}
#aboutmenu{font-weight:900;font-size:16px;}
/* ---------- floating windows ---------- */
.win{
position:absolute;z-index:10;background:#fff;
border:2px solid #000;box-shadow:var(--winshadow);
}
.win.focused{z-index:60;}
/* utility palettes always float above the document window */
#toolbox,#patwin,#layerwin{z-index:40;}
#toolbox.focused,#patwin.focused,#layerwin.focused{z-index:70;}
/* title bar: the striped MacPaint look */
.titlebar{
height:18px;border-bottom:2px solid #000;display:flex;align-items:center;
position:relative;cursor:move;
background:
repeating-linear-gradient(to bottom,#000 0,#000 1px,#fff 1px,#fff 2px);
}
.titlebar .closebox{
width:11px;height:11px;border:2px solid #000;background:#fff;margin:0 5px;flex:0 0 auto;cursor:pointer;
}
.titlebar .ttl{
background:#fff;padding:0 8px;font-size:12px;line-height:1;margin:0 auto;
white-space:nowrap;border-left:2px solid #000;border-right:2px solid #000;
}
.titlebar.plain{background:#fff;}
.win .body{position:relative;}
/* ---------- toolbox ---------- */
#toolbox{left:14px;top:45px;width:auto;}
#toolbox .body{display:grid;grid-template-columns:repeat(2,33px);gap:0;background:#000;}
.toolbtn{
width:33px;height:30px;background:#fff;display:flex;align-items:center;justify-content:center;
cursor:pointer;border:1px solid #000;margin:-0.5px;
}
.toolbtn img{width:26px;height:24px;object-fit:contain;image-rendering:pixelated;pointer-events:none;}
.toolbtn.active{background:#000;}
.toolbtn.active img{filter:invert(1);}
/* line-width selector under the tools */
#lwsel{margin-top:0;border-top:2px solid #000;background:#fff;padding:5px 6px;display:flex;flex-direction:column;gap:5px;align-items:stretch;}
.lwrow{display:flex;align-items:center;gap:6px;height:11px;cursor:pointer;}
.lwrow .chk{width:12px;text-align:center;font-size:12px;}
.lwrow .bar{flex:1;background:#000;}
/* ---------- pattern palette ---------- */
#patwin{left:110px;top:710px;}
#patwin .body{display:flex;align-items:stretch;background:#000;gap:0;padding:0;}
#curpat{width:64px;height:64px;border-right:2px solid #000;flex:0 0 auto;image-rendering:pixelated;}
#patgrid{display:grid;grid-template-rows:repeat(4,32px);grid-auto-flow:column;grid-auto-columns:32px;gap:2px;background:#000;padding:2px;}
.patcell{width:32px;height:32px;border:2px solid #000;outline:1px solid #fff;margin:0;image-rendering:pixelated;cursor:pointer;}
.patcell.active{outline:3px solid var(--orange);outline-offset:0;z-index:2;}
/* ---------- document window ---------- */
#docwin{left:110px;top:45px;}
#docwin .titlebar{cursor:move;}
#doctools{height:22px;background:#ddd;border-bottom:2px solid #000;display:flex;align-items:center;gap:8px;padding:0 8px;font-size:11px;font-weight:700;color:#333;}
#doctools button{font:inherit;font-size:11px;border:1px solid #000;background:#eee;cursor:pointer;padding:0 6px;border-radius:3px;}
#doctools button.on{background:#000;color:#fff;}
/* Fixed-size scrollable viewport: zoom scales the canvas inside, the window
stays put and shows scrollbars when the content is larger than the viewport. */
#canvaswrap{position:relative;background:#999;overflow:auto;width:768px;height:576px;max-width:calc(100vw - 40px);max-height:calc(100vh - 160px);}
#cvstage{position:relative;}
#cv{display:block;image-rendering:pixelated;cursor:crosshair;background:#777;}
#overlay{position:absolute;left:0;top:0;pointer-events:none;image-rendering:pixelated;}
/* ---------- layers window ---------- */
#layerwin{left:725px;top:45px;width:250px;}
#layerwin .body{background:#fff;}
#layerlist{max-height:280px;overflow:auto;}
.layeritem{display:flex;align-items:center;gap:6px;padding:4px 6px;border-bottom:1px solid #000;font-size:12px;font-weight:700;cursor:pointer;background:#fff;}
.layeritem.active{background:#000;color:#fff;}
.layeritem.active .lthumb{outline:1px solid #fff;}
.layeritem .eye{width:16px;text-align:center;cursor:pointer;flex:0 0 auto;}
.layeritem .lthumb{width:42px;height:32px;border:1px solid #000;image-rendering:pixelated;flex:0 0 auto;background:#fff;}
.layeritem .lname{flex:1;outline:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.layeritem.dragover{box-shadow:inset 0 3px 0 var(--orange);}
#layerbtns{display:flex;flex-wrap:nowrap;gap:4px;padding:6px;border-top:2px solid #000;justify-content:space-between;}
#layerbtns button{font:inherit;font-size:11px;font-weight:700;border:2px solid #000;background:#fff;cursor:pointer;padding:2px 6px;border-radius:9px;box-shadow:1px 1px 0 #000;white-space:nowrap;}
#layerbtns button:active{box-shadow:none;transform:translate(1px,1px);}
/* ---------- disk gallery window ---------- */
#diskwin{left:990px;top:45px;width:430px;z-index:50;}
#previewwin{left:725px;top:330px;}
#diskwin.focused{z-index:80;}
#diskwin .body{background:#fff;}
#diskinfo{font-size:11px;font-weight:700;padding:5px 8px;border-bottom:2px solid #000;background:#ddd;color:#222;}
#diskgallery{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;padding:8px;max-height:340px;overflow:auto;}
.diskitem{display:flex;flex-direction:column;align-items:center;gap:3px;padding:4px;border:2px solid #000;cursor:pointer;background:#fff;font-size:10px;font-weight:700;}
.diskitem.active{background:#000;color:#fff;}
.diskitem .dthumb{width:80px;height:60px;border:1px solid #000;image-rendering:pixelated;background:#888;}
.diskitem .dname{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:84px;}
.diskitem.active .dthumb{outline:1px solid #fff;}
#diskbtns{display:flex;flex-wrap:wrap;gap:4px;padding:6px;border-top:2px solid #000;justify-content:space-between;}
#diskbtns button{font:inherit;font-size:11px;font-weight:700;border:2px solid #000;background:#fff;cursor:pointer;padding:2px 7px;border-radius:9px;box-shadow:1px 1px 0 #000;white-space:nowrap;}
#diskbtns button:active{box-shadow:none;transform:translate(1px,1px);}
#diskbtns button:disabled{color:#aaa;box-shadow:none;cursor:default;}
/* ---------- modal dialogs ---------- */
#modal{position:fixed;inset:0;z-index:2000;display:none;align-items:center;justify-content:center;background:rgba(0,0,0,.25);}
#dlg{background:#fff;border:2px solid #000;box-shadow:5px 5px 0 rgba(0,0,0,.55);padding:18px 20px;min-width:320px;max-width:90vw;max-height:90vh;overflow:auto;}
#dlg h3{margin:0 0 12px;font-size:16px;}
#dlg .row{display:flex;gap:10px;justify-content:flex-end;margin-top:16px;flex-wrap:wrap;}
.btn{font:inherit;font-weight:700;font-size:13px;border:2px solid #000;background:#fff;cursor:pointer;padding:4px 16px;border-radius:11px;box-shadow:1px 1px 0 #000;}
.btn:active{transform:translate(1px,1px);box-shadow:none;}
.btn.default{box-shadow:0 0 0 2px #000, 2px 2px 0 #000;}
.txt{font:inherit;font-size:14px;border:2px solid #000;padding:5px 8px;width:100%;}
label.fld{display:block;margin:8px 0;font-size:13px;font-weight:700;}
select.txt,input[type=number].txt{width:auto;}
/* color-mapping dialog */
#mapgrid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:8px;}
.mapcell{border:2px solid #000;padding:5px;display:flex;flex-direction:column;align-items:center;gap:4px;font-size:11px;}
.mapcell .sw{width:42px;height:28px;border:1px solid #000;}
.mapcell .pat{width:42px;height:42px;border:1px solid #000;image-rendering:pixelated;cursor:pointer;}
.mapcell .pat:hover{outline:2px solid var(--orange);}
/* text-tool insertion overlay */
#textentry{position:absolute;z-index:80;display:none;}
#textentry input{font-family:'Times New Roman',Georgia,serif;font-weight:700;border:1px dotted #00f;background:rgba(255,255,255,.6);padding:0;outline:none;}
.hidden{display:none !important;}
</style>
</head>
<body>
<div id="desktop"></div>
<!-- ===== Menu bar ===== -->
<div id="menubar">
<div class="menu" data-menu="about" id="aboutmenu">✿<div class="dropdown" id="m-about"></div></div>
<div class="menu" data-menu="file">File<div class="dropdown" id="m-file"></div></div>
<div class="menu" data-menu="edit">Edit<div class="dropdown" id="m-edit"></div></div>
<div class="menu" data-menu="goodies">Goodies<div class="dropdown" id="m-goodies"></div></div>
<div class="menu" data-menu="font">Font<div class="dropdown" id="m-font"></div></div>
<div class="menu" data-menu="style">Style<div class="dropdown" id="m-style"></div></div>
<div class="menu" data-menu="windows">Windows<div class="dropdown" id="m-windows"></div></div>
</div>
<!-- ===== Toolbox window ===== -->
<div class="win" id="toolbox">
<div class="titlebar plain" style="height:11px"></div>
<div class="body">
<div id="toolgrid" style="display:contents"></div>
</div>
<div id="lwsel"></div>
</div>
<!-- ===== Document window ===== -->
<div class="win focused" id="docwin">
<div class="titlebar"><span class="closebox" data-close="doc"></span><span class="ttl" id="doctitle">untitled</span></div>
<div id="doctools">
<span>View:</span>
<button id="zoomout">–</button><span id="zoomlabel">100%</span><button id="zoomin">+</button>
<button id="fatbtn">Fat Bits</button>
<button id="dispbtn">Composite</button>
<button id="ntscbtn">NTSC Fringe</button>
<button id="cssbtn">CSS 0</button>
<span id="coord" style="margin-left:auto"></span>
</div>
<div class="body">
<div id="canvaswrap">
<div id="cvstage">
<canvas id="cv"></canvas>
<canvas id="overlay"></canvas>
<div id="textentry"><input id="textinput" type="text" spellcheck="false"></div>
</div>
</div>
</div>
</div>
<!-- ===== Layers window ===== -->
<div class="win" id="layerwin">
<div class="titlebar"><span class="ttl">Layers</span></div>
<div class="body">
<div id="layerlist"></div>
<div id="layerbtns">
<button id="lnew">New</button>
<button id="lmerge">Merge</button>
<button id="ldup">Dup</button>
<button id="ldel">Del</button>
</div>
</div>
</div>
<!-- ===== Preview window (1:1, 256x192) ===== -->
<div class="win" id="previewwin">
<div class="titlebar"><span class="closebox" data-close="preview"></span><span class="ttl">Preview</span></div>
<div class="body" style="background:#777;padding:0;line-height:0">
<canvas id="previewcv" width="256" height="192" style="display:block;image-rendering:pixelated;width:256px;height:192px"></canvas>
</div>
</div>
<!-- ===== Pattern palette window ===== -->
<div class="win" id="patwin">
<div class="titlebar"><span class="ttl">Patterns</span></div>
<div class="body">
<canvas id="curpat" width="64" height="64"></canvas>
<div id="patgrid"></div>
</div>
</div>
<!-- ===== Disk gallery window ===== -->
<div class="win hidden" id="diskwin">
<div class="titlebar"><span class="closebox" data-close="disk"></span><span class="ttl" id="disktitle">Disk</span></div>
<div class="body">
<div id="diskinfo"></div>
<div id="diskgallery"></div>
<div id="diskbtns">
<button id="dkload">Load</button>
<button id="dksave">Save→Disk</button>
<button id="dkdup">Duplicate</button>
<button id="dkdel">Delete</button>
<button id="dkdownload">Download .DSK</button>
</div>
</div>
</div>
<!-- ===== Modal ===== -->
<div id="modal"><div id="dlg"></div></div>
<input type="file" id="fileinput" class="hidden">
<script>
const PATTERNS_64_DATA=[[255,255,255,255,255,255,255,255],[0,0,0,0,0,0,0,0],[170,170,170,170,170,170,170,170],[85,85,85,85,85,85,85,85],[238,238,238,238,238,238,238,238],[17,17,17,17,17,17,17,17],[186,186,186,186,186,186,186,186],[69,69,69,69,69,69,69,69],[255,255,0,255,255,0,255,255],[0,0,255,0,0,255,0,0],[170,170,0,170,170,0,170,170],[85,85,255,85,85,255,85,85],[255,170,255,85,255,170,255,85],[0,85,0,170,0,85,0,170],[170,85,170,85,170,85,170,85],[85,170,85,170,85,170,85,170],[255,255,223,255,255,247,255,255],[0,0,32,0,0,8,0,0],[170,170,138,170,170,162,170,170],[85,85,117,85,85,93,85,85],[255,239,255,255,255,251,255,255],[0,16,0,0,0,4,0,0],[170,186,170,170,170,174,170,170],[85,69,85,85,85,81,85,85],[255,221,255,119,255,221,255,119],[0,34,0,136,0,34,0,136],[170,136,170,34,170,136,170,34],[85,119,85,221,85,119,85,221],[255,255,255,0,255,255,255,0],[0,0,0,255,0,0,0,255],[170,170,170,0,170,170,170,0],[85,85,85,255,85,85,85,255],[255,255,0,0,255,255,0,0],[0,0,255,255,0,0,255,255],[170,170,0,0,170,170,0,0],[85,85,255,255,85,85,255,255],[238,238,238,238,238,238,238,238],[17,17,17,17,17,17,17,17],[187,187,187,187,187,187,187,187],[68,68,68,68,68,68,68,68],[204,204,204,204,204,204,204,204],[51,51,51,51,51,51,51,51],[153,153,153,153,153,153,153,153],[102,102,102,102,102,102,102,102],[255,255,204,255,255,51,255,255],[0,0,51,0,0,204,0,0],[170,170,153,170,170,51,170,170],[85,85,102,85,85,204,85,85],[238,238,255,255,119,119,255,255],[17,17,0,0,136,136,0,0],[186,186,170,170,34,34,170,170],[69,69,85,85,221,221,85,85],[254,253,251,247,239,223,191,127],[1,2,4,8,16,32,64,128],[171,168,174,162,186,138,234,42],[84,87,81,93,69,117,21,213],[127,191,223,239,247,251,253,254],[128,64,32,16,8,4,2,1],[42,234,138,186,162,174,168,171],[213,21,117,69,93,81,87,84],[255,136,170,136,255,136,170,136],[0,119,85,119,0,119,85,119],[170,34,170,34,170,34,170,34],[85,221,85,221,85,221,85,221]];
const PAT_NAMES=["Solid White","Solid Black","Solid Blue Artifact","Solid Orange Artifact","White with thin black vertical pinstripes","Black with thin white vertical pinstripes","Blue vertical phase A","Orange vertical phase A","White with thin black horizontal pinstripes","Black with thin white horizontal pinstripes","Blue horizontal artifact bands","Orange horizontal artifact bands","White light checker","Black dark checker","Blue checker phase","Orange checker phase","White sparse black dots","Black sparse white dots","Blue sparse shifted dots","Orange sparse shifted dots","White offset black dots","Black offset white dots","Blue offset dots","Orange offset dots","White dense dotted fabric","Black dense dotted fabric","Blue dense dotted fabric","Orange dense dotted fabric","White thin horizontal stripes","Black thin horizontal stripes","Blue thin horizontal stripes","Orange thin horizontal stripes","White two-pixel horizontal stripes","Black two-pixel horizontal stripes","Blue two-pixel horizontal stripes","Orange two-pixel horizontal stripes","White thin vertical stripes","Black thin vertical stripes","Blue thin vertical stripes","Orange thin vertical stripes","White two-pixel vertical stripes","Black two-pixel vertical stripes","Blue two-pixel vertical stripes","Orange two-pixel vertical stripes","White dashed horizontal","Black dashed horizontal","Blue dashed horizontal","Orange dashed horizontal","White dashed vertical","Black dashed vertical","Blue dashed vertical","Orange dashed vertical","White rising diagonal","Black rising diagonal","Blue rising diagonal","Orange rising diagonal","White falling diagonal","Black falling diagonal","Blue falling diagonal","Orange falling diagonal","White grid","Black grid","Blue grid","Orange grid"];
const ICON={
lasso:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAUCAYAAACJfM0wAAAAbUlEQVR4nLWUwRoAEAiD8f7vzIlD35oa7Yj9Uqk3X9NZ78RDNQk0su+avp9NR8E8Q4CFVAa2KslxxqDU4xiROdxqt2a3EPlzMLD8fAZOQ1m7PUXogTdUzikCP0MRGEG/Fg1dltJtVpREKw30ci19KR734JDg6wAAAABJRU5ErkJggg==",
marquee:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAUCAYAAACJfM0wAAAAL0lEQVR4nGNgoAP4D8XUEEOXpw2gli30cS01bRp1Me1tGnUx7W2ir4uHVuk2wgEADnUd4wQl/uIAAAAASUVORK5CYII=",
hand:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAUCAYAAACJfM0wAAAAa0lEQVR4nOWUSw4AIQhD1Xj/K+PWNHyqYGYx3WgCPAsxtPahBM4nUFFiZVCqg3H7aqQs2HSNYPGSQd0LToD27Z6SNQrXjWIkTGR13BFbcDUqpijMefaPLXmO0j9GA5QtIgTR4Bmn1K7LH2oBFhMd+angMT8AAAAASUVORK5CYII=",
text:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAUCAYAAACJfM0wAAAAWklEQVR4nO2TQQ5AEQwFh7j/lWsnFuir/1mZFUlfTYOEj3XrJNQDkNXCKF5jc/bbjY9gjI0l6+vGKzPJ+pjx6F3KNz/JA1B2QsHDW0ANLWuv/LzweB9zj8dfVNrhEwUOoaBRAAAAAElFTkSuQmCC",
bucket:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAUCAYAAACJfM0wAAAAbklEQVR4nM2SQQrAMAgEY8n/v5yeCiE2OiqhnVMI7rqorR1CNv+joDXxjEljJaAis1aWQpneHnOtGksHwjdSoyBCWqdExDicmiRJmXpiZHpVOldY0+ERWOcWNouSOq2oOYYsL5X4s6t4OLbA/3ADqOcf9mqMMJIAAAAASUVORK5CYII=",
spray:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAUCAYAAACJfM0wAAAAUElEQVR4nGNgIB38J8Cn2EBS5UmyhKqGEcVnJMEgfGqJBti8SJK3WUg0nGJAsYuxhRsuAygOY4pdy8DAwMBEqStGDR44g3FlEJpkjlFAXQAAgnETBCLmqIUAAAAASUVORK5CYII=",
brush:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAUCAYAAACJfM0wAAAAUklEQVR4nO2QWwoAIAgEtfvf2T6LHrYaEoHzo4gOi0w40vV8Wi4GMST0imH+E6M/k8VMvUUSr6TaHBa7SLFZzJt6LQ5BgH7iaWKilm6sIfIEowJ2hAwVjJAN4wAAAABJRU5ErkJggg==",
pencil:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAUCAYAAACJfM0wAAAAS0lEQVR4nO2UMQ4AIAgDD///Z1yRKIN2k050uRQSamjkYTaAIYRa9K/gDJXIWU+QfUMb2tCTdi+dAbK3jemuV69KyHlIWYGljfWxJshsMeAJsdmHAAAAAElFTkSuQmCC",
line:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAUCAYAAACJfM0wAAAAKElEQVR4nO3MQREAAAzCsN78e2Ym4NcICPQF4BZpWxaxqampqWk3rnnuHiTe4GWI6AAAAABJRU5ErkJggg==",
eraser:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAUCAYAAACJfM0wAAAAT0lEQVR4nGNgGGqAkUh1/6lkDoah+AwmJE+2oQwE1JBtKEkGk2IoVoNZKHTJfwYSIo4YbxEMWyZibcNhOE7XYpMgNiLISrujYBSMAgYGBgATQxj02eRZ7gAAAABJRU5ErkJggg==",
rect:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAUCAYAAACJfM0wAAAALklEQVR4nGNgGGqAEYvYfxqYSbHBcL1MFBiCF4waPGowJmDBIU5pJhkFo2BYAADwdQQROIZLxQAAAABJRU5ErkJggg==",
rectf:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAUCAYAAACJfM0wAAAAMklEQVR4nGNgGGqAEYvYf2qYyUSEpaTSOMF/Cmi8vkVWQC496uKh7GKaZZBRMAqGAwAA3aVAzSiKwpcAAAAASUVORK5CYII=",
rrect:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAUCAYAAACJfM0wAAAAK0lEQVR4nGNgGGqAEYf4fyqbR5GhKHqZKDAELxg1eNRgTEDXDDIKRsFQAwDl6wQPhYU/ewAAAABJRU5ErkJggg==",
rrectf:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAUCAYAAACJfM0wAAAAL0lEQVR4nGNgGGqAEYf4fyqbBzf0PxKbHBqnweTSBA0edfFIdjFdM8goGAVDDQAA7nBJu9nwlMEAAAAASUVORK5CYII=",
oval:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAUCAYAAACJfM0wAAAARUlEQVR4nGNgoBFgJELNfwr1YzUQn6HEqsHQQHX1pBpKlD5yDcXQz0ShQTjB0DMYG6BJ5JFrOMlpmewMMiBZehSMAioDAB9sE/dp7Y6nAAAAAElFTkSuQmCC",
ovalf:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAUCAYAAACJfM0wAAAATElEQVR4nGNgoBFgJELNf3L0MxEwEGYoIw4aWQ1R4D+ZNE0MxWs4pYbiNPw/FkWk0HgNHloupsRwolMG1Q0l1XCshtIsS4+CUUBHAADSY1K37qCweQAAAABJRU5ErkJggg==",
free:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAUCAYAAACJfM0wAAAAXUlEQVR4nO2UMQ4AIAgDxf//GVej2FKJm92M7UHAaA3Ll7MRPzX64X4ulC4ShZEn45OgslfqoJGuuwibBWdcAUt6MmPFrO4jFbqCsuA1FAHK0AgkQ9FbLP0LX1+7BgVHF/U6+SVhAAAAAElFTkSuQmCC",
freef:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAUCAYAAACJfM0wAAAAWklEQVR4nO3UQQoAIAgEQO3/f7arWKmbddPLEiwTFcTkj5g1B/2wKKYjKuFNLOql0HqaMqrx5yiE6yKaIYzml+uAH+85msWv0BNWRj28jO5wGB1JHP4XenrWmWeoSr+kqNs3AAAAAElFTkSuQmCC",
poly:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAUCAYAAACJfM0wAAAATElEQVR4nGNgoBFgJEHtfwr1E20oTByXHNmGkqqGLA0kGTzCDMWWXIhJVv9x6CUJoFtEdvLCZzDVDEU2jKqGwgykuqEwg0fBKKACAAAGLhjtefOykgAAAABJRU5ErkJggg==",
polyf:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAUCAYAAACJfM0wAAAAU0lEQVR4nGNgoBFgJEHtf1L0M5FoKCMa/R+HhUQb+h+JjY+muqEkGTzCDMWWXHAlq/9INC69JAF0H5CdvLAZTLKh5GYQqhlMkqHEAqqF6SgY6QAALhox32Q+i28AAAAASUVORK5CYII=",
dropper:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAUCAYAAACJfM0wAAAAVElEQVR4nO3UQQ4AEAxEUXX/O9e6MrXoF5EwO7N4SoI1Fp/WBj2Jhq7v2EGlOrqalnhLOHSVq8imRXEBq+6jL6EoR1B89AwtJ3vS3uCHomCMKvD+DF/cLuApeCapAAAAAElFTkSuQmCC",
wand:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAUCAYAAACJfM0wAAAAVklEQVR4nO3RwRIAEAgE0OX//zknDkTJXgydzDReOwXwSojWgB7jGkBLLMqbimsDKGCPU1BgguYACgDJ6G+j1sfW9yZeJQ3vd+c47iG0i3/0QrTCD1cBwMko3n9Xg1IAAAAASUVORK5CYII=",
crop:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAUCAYAAACJfM0wAAAAPUlEQVR4nGNgwA3+45EjCJgo0TwgBjMiscnxOiNhJZgAn0UEHTH0wnjUYDjAl1zolvxIsnTohfEoGAWYAAAWfgYRcr5vRgAAAABJRU5ErkJggg==",
resize:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAUCAYAAACJfM0wAAAAU0lEQVR4nGNgoBFgROP/p0AvXkCswQTVMZFiKylg6BnMQiVzMMKcZi6mZnL7j0+SXPAfahYpDiPKUHT+f0rDGOZSqgJc3qYoOKgellQ1cBQMUgAA/SsRBZ7YgwMAAAAASUVORK5CYII="
};
/* ===================== Geometry & palette ===================== */
const W=256, H=192, BPR=32, PAGE_BYTES=BPR*H; // PMODE 4 page = 6144 bytes
let SCALE=3; // on-screen zoom
const COL={ black:[0,0,0], white:[236,236,236], blue:[40,70,210], orange:[232,120,28] };
/* ===================== Pattern library (64) =====================
From coco_mark_data_64_patterns.json. Convention: bit 1 = WHITE pixel,
0 = BLACK. Blue artifact = 0xAA (10101010), Orange artifact = 0x55.
Layout is 16 columns x 4 rows, IDs ordered column-major: IDs 1-4 are
column 1 top-to-bottom (white/black/blue/orange base), 5-8 column 2, etc.
PAT64[i] is pattern id (i+1). */
const PAT64=PATTERNS_64_DATA; /* injected: array of 64 [8 bytes] */
const PAT_COLS=16, PAT_ROWS=4;
const TILE_WHITE =[0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF];
const TILE_BLACK =[0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00];
const TILE_BLUE =[0xAA,0xAA,0xAA,0xAA,0xAA,0xAA,0xAA,0xAA]; /* AA = blue per JSON */
const TILE_ORANGE=[0x55,0x55,0x55,0x55,0x55,0x55,0x55,0x55]; /* 55 = orange per JSON */
/* The palette IS the 64-pattern library, in ID order. */
const PALETTE=PAT64;
/* Text-tool fonts — Google pixel/retro fonts. Each entry maps the menu label
to a CSS font-family and a recommended pixel size for the 256x192 canvas. */
const FONT_FAMILIES=[
{name:"Pixelify Sans", css:"'Pixelify Sans', sans-serif", px:18},
{name:"Doto", css:"'Doto', sans-serif", px:20},
{name:"Tiny5", css:"'Tiny5', sans-serif", px:16},
{name:"Micro 5", css:"'Micro 5', sans-serif", px:20},
{name:"Jacquarda Bastarda 9", css:"'Jacquarda Bastarda 9', system-ui", px:22},
{name:"Bytesized", css:"'Bytesized', sans-serif", px:20},
];
const FONTS=FONT_FAMILIES.map(f=>({name:f.name, css:f.css, px:f.px, size:""}));
/* ===================== TC1014 RGB222 (CoCo 3) 64-color palette ===================== */
function rgb222(byte){
const b=byte&0x3f;
const r=(((b>>4)&2)|((b>>2)&1))*85;
const g=(((b>>3)&2)|((b>>1)&1))*85;
const bl=(((b>>2)&2)|(b&1))*85;
return [r,g,bl];
}
/* ===================== Layer model =====================
Each layer owns a 256x192 1bpp bitmap (1=lit pixel) plus a transparency
mask (1=pixel participates). For the base layer everything participates;
imported/painted layers track which pixels are "set" so layers below show
through transparent regions. Compositing is top-most-set-pixel wins. */
let layers=[]; // [{id,name,visible,bmp:Uint8Array, mask:Uint8Array}]
let activeLayer=0;
let layerSeq=1;
function newBmp(fillLit){ return new Uint8Array(W*H).fill(fillLit?1:0); }
function makeLayer(name,opaqueWhite){
// opaqueWhite: base layer is fully opaque, initialised to white (lit)
const bmp=newBmp(opaqueWhite?1:0);
const mask=newBmp(opaqueWhite?1:0); // base: all participate; others: empty
return {id:layerSeq++, name:name||('Layer '+layerSeq), visible:true, bmp, mask};
}
function curLayer(){ return layers[activeLayer]; }
function initLayers(){
layers=[ makeLayer('Background', true) ];
activeLayer=0;
}
/* composite all visible layers into a single 1bpp page (top set pixel wins).
Pixels with no set layer default to white (lit), matching a blank CoCo page. */
function composite(){
const out=new Uint8Array(W*H).fill(1);
const seen=new Uint8Array(W*H);
for(let li=layers.length-1; li>=0; li--){
const L=layers[li]; if(!L.visible) continue;
const b=L.bmp, m=L.mask;
for(let i=0;i<out.length;i++){
if(!seen[i] && m[i]){ out[i]=b[i]; seen[i]=1; }
}
}
return out;
}
/* ---------- per-layer pixel helpers (operate on the ACTIVE layer) ---------- */
function inb(x,y){ return x>=0&&x<W&&y>=0&&y<H; }
function setLayerPx(x,y,lit){ if(inb(x,y)){const L=curLayer();const i=y*W+x;L.bmp[i]=lit?1:0;L.mask[i]=1;} }
function clearLayerPx(x,y){ if(inb(x,y)){const L=curLayer();const i=y*W+x;L.bmp[i]=0;L.mask[i]=0;} }
function getLayerLit(x,y){ const L=curLayer(); return inb(x,y)?L.bmp[y*W+x]:0; }
/* ===================== Pattern-aware ink ===================== */
let curPattern=TILE_BLACK.slice();
let curPatternIndex=0;
function patBit(x,y){ return (curPattern[y&7] >> (7-(x&7))) & 1; }
/* drawing modes */
let drawMode='paint'; // paint | erase
function plot(x,y){
if(!inb(x,y)) return;
if(drawMode==='erase'){ clearLayerPx(x,y); } // erase => make transparent
else setLayerPx(x,y, patBit(x,y)); // paint => stamp pattern bit
}
let lineWidth=1;
function plotThick(x,y){
const r=lineWidth; if(r<=1){plot(x,y);return;}
const h=Math.floor(r/2);
for(let dy=-h;dy<=h-(r%2===0?1:0);dy++)
for(let dx=-h;dx<=h-(r%2===0?1:0);dx++) plot(x+dx,y+dy);
}
/* ===================== Canvas & render ===================== */
let cssBit=0, dispMode='composite', ntscFringe=false;
const cv=document.getElementById('cv');
const overlay=document.getElementById('overlay');
const ctx=cv.getContext('2d');
const octx_holder=document.createElement('canvas'); octx_holder.width=W; octx_holder.height=H;
const octx=octx_holder.getContext('2d');
let imgData=octx.createImageData(W,H);
function sizeCanvas(){
cv.width=W*SCALE; cv.height=H*SCALE;
overlay.width=W*SCALE; overlay.height=H*SCALE;
ctx.imageSmoothingEnabled=false;
}
/* artifact shader: render the composited page. Each 2-pixel chroma cell maps
to a SOLID color block (composite bleeds chroma horizontally):
11=>white 10=>orange 01=>blue 00=>black ; CSS swaps orange<->blue. */
function render(){
const page=composite();
const d=imgData.data;
const oCol = cssBit? COL.blue : COL.orange;
const bCol = cssBit? COL.orange : COL.blue;
for(let y=0;y<H;y++){
const row=y*W;
for(let x=0;x<W;x+=2){
const hi=page[row+x], lo=(x+1<W)?page[row+x+1]:0;
if(dispMode==='rgb'){
for(const [xx,bit] of [[x,hi],[x+1,lo]]){
const i=(row+xx)*4, c=bit?COL.white:COL.black;
d[i]=c[0];d[i+1]=c[1];d[i+2]=c[2];d[i+3]=255;
}
} else {
let c;
if(hi&&lo) c=COL.white;
else if(hi&&!lo) c=bCol; // even-col lit => blue (0xAA), CSS swaps
else if(!hi&&lo) c=oCol; // odd-col lit => orange (0x55)
else c=COL.black;
for(const xx of [x,x+1]){
const i=(row+xx)*4; d[i]=c[0];d[i+1]=c[1];d[i+2]=c[2];d[i+3]=255;
}
}
}
}
if(ntscFringe && dispMode!=='rgb'){ applyNTSCFringe(d); }
octx.putImageData(imgData,0,0);
ctx.clearRect(0,0,cv.width,cv.height);
ctx.drawImage(octx_holder,0,0,cv.width,cv.height);
// Preview window: exact 1:1 256x192 copy of the rendered image
const pcv=document.getElementById('previewcv');
if(pcv){ const pctx=pcv.getContext('2d'); pctx.clearRect(0,0,W,H); pctx.drawImage(octx_holder,0,0); }
if(gridOn) drawGridOverlay();
if(rulersOn) drawRulers();
if(fatBits) renderFatBits();
drawSelOverlay();
scheduleThumbs();
}
/* NTSC composite simulation. Converts RGB to YIQ, then bandlimits the chroma
(I/Q) slightly more than the luma — exactly what an NTSC composite signal
does. Kept deliberately TIGHT: a single bright pixel on a contrasting
background gets only ~1px of colored fringe on each side (e.g. a blue pixel
on white fringes toward ~#a7e4ff), matching a real CoCo on composite. The
image stays crisp and readable — no heavy smearing. Operates on a flat RGBA
byte buffer `d` of dimensions w x h. */
function ntscProcess(d, w, h){
const N=w*h;
const Y=new Float32Array(N), I=new Float32Array(N), Q=new Float32Array(N);
for(let p=0;p<N;p++){
const j=p*4, r=d[j]/255, g=d[j+1]/255, b=d[j+2]/255;
Y[p]=0.299*r+0.587*g+0.114*b;
I[p]=0.596*r-0.274*g-0.322*b;
Q[p]=0.211*r-0.523*g+0.312*b;
}
// tight horizontal box blur (n taps) per row
function hblur(buf,n){
const out=new Float32Array(N), half=(n-1)/2;
for(let y=0;y<h;y++){
const row=y*w;
for(let x=0;x<w;x++){
let acc=0;
for(let k=-half;k<=half;k++){ acc+=buf[row+Math.min(w-1,Math.max(0,x+k))]; }
out[row+x]=acc/n;
}
}
return out;
}
const Yb=hblur(Y,3); // luma: barely softened (1px) so pixels aren't razor-hard
const Ib=hblur(I,3); // chroma: ~1px bleed each side (was 7 -> far too much)
const Qb=hblur(Q,3);
for(let p=0;p<N;p++){
const j=p*4, y=Yb[p], i=Ib[p], q=Qb[p];
let r=y+0.956*i+0.621*q, g=y-0.272*i-0.647*q, b=y-1.106*i+1.703*q;
d[j] =r<0?0:r>1?255:(r*255)|0;
d[j+1]=g<0?0:g>1?255:(g*255)|0;
d[j+2]=b<0?0:b>1?255:(b*255)|0;
}
}
function applyNTSCFringe(d){ ntscProcess(d, W, H); }
function pageToBmp(page){
const bm=new Uint8Array(W*H);
for(let y=0;y<H;y++)for(let bx=0;bx<BPR;bx++){
const b=page[y*BPR+bx]||0;
for(let bit=0;bit<8;bit++) bm[y*W+bx*8+bit]=(b>>(7-bit))&1;
}
return bm;
}
function bmpToPage(bm){
const page=new Uint8Array(PAGE_BYTES);
for(let y=0;y<H;y++)for(let bx=0;bx<BPR;bx++){
let b=0; for(let bit=0;bit<8;bit++) if(bm[y*W+bx*8+bit]) b|=(1<<(7-bit));
page[y*BPR+bx]=b;
}
return page;
}
/* ---------- LOADM (.MAX/.BIN) ---------- */
function readLoadm(bytes){
let i=0; const segs=[]; const mem=new Uint8Array(0x10000); let exec=null;
while(i<bytes.length){
const flag=bytes[i];
if(flag===0xFF){ exec=(bytes[i+3]<<8)|bytes[i+4]; break; }
if(flag!==0x00) break;
const len=(bytes[i+1]<<8)|bytes[i+2], addr=(bytes[i+3]<<8)|bytes[i+4]; i+=5;
for(let k=0;k<len&&i+k<bytes.length;k++) mem[(addr+k)&0xFFFF]=bytes[i+k];
segs.push({addr,len}); i+=len;
}
let start=0x0E00;
if(segs.length && !segs.some(s=>s.addr<=0x0E00&&0x0E00<s.addr+s.len)) start=segs[0].addr;
return {page:mem.slice(start,start+PAGE_BYTES), exec, loadAddr:start};
}
function writeLoadm(page,loadAddr=0x0E00,execAddr=0x492B){
const out=new Uint8Array(5+PAGE_BYTES+5); let p=0;
out[p++]=0x00; out[p++]=(PAGE_BYTES>>8)&0xFF; out[p++]=PAGE_BYTES&0xFF;
out[p++]=(loadAddr>>8)&0xFF; out[p++]=loadAddr&0xFF;
out.set(page,p); p+=PAGE_BYTES;
out[p++]=0xFF;out[p++]=0;out[p++]=0;out[p++]=(execAddr>>8)&0xFF;out[p++]=execAddr&0xFF;
return out;
}
/* ===================== CoCo 3 indexed-image decoders =====================
Each returns {w,h,indices:Uint8Array(w*h of 0..15), pal:[[r,g,b]*16], title?}.
Colormaps are TC1014 RGB222 bytes -> rgb222(). Specs from the .dox files. */
function unpack4bpp(data, count){ // 2 px/byte, high nibble = left pixel
const out=new Uint8Array(count);
for(let i=0;i<count;i++){
const b=data[i>>1]||0;
out[i]=(i&1)? (b&0x0F) : (b>>4);
}
return out;
}
function unpack2bpp(data, count){ // 4 px/byte
const out=new Uint8Array(count);
for(let i=0;i<count;i++){
const b=data[i>>2]||0; const sh=6-2*(i&3);
out[i]=(b>>sh)&0x03;
}
return out;
}
function unpack1bpp(data,count){
const out=new Uint8Array(count);
for(let i=0;i<count;i++){ const b=data[i>>3]||0; out[i]=(b>>(7-(i&7)))&1; }
return out;
}
function palFromBytes(bytes,n){ const p=[]; for(let i=0;i<16;i++) p.push(rgb222(i<n?(bytes[i]||0):0)); return p; }
/* ---- HRS (Davinci 3): 16-byte colormap + 320x192 4bpp uncompressed ---- */
function decodeHRS(bytes){
const pal=palFromBytes(bytes.subarray(0,16),16);
const idx=unpack4bpp(bytes.subarray(16), 320*192);
return {w:320,h:192,indices:idx,pal,fmt:'HRS'};
}
/* ---- MGE (ColorMax Deluxe): 51-byte header, optional RLE, 320x200 4bpp ---- */
function decodeMGE(bytes){
const pal=palFromBytes(bytes.subarray(1,17),16);
const compFlag=bytes[18]; // 0 = compressed (per spec)
let title=''; for(let i=19;i<49;i++){const c=bytes[i]; if(!c)break; title+=String.fromCharCode(c);}
const want=320*200, packed=new Uint8Array(want/2);
const data=bytes.subarray(51);
if(compFlag===0){
// RLE byte pairs (count,value); count 0 = end
let o=0,i=0;
while(i+1<data.length && o<packed.length){
const n=data[i++], v=data[i++]; if(n===0) break;
for(let k=0;k<n && o<packed.length;k++) packed[o++]=v;
}
} else {
packed.set(data.subarray(0,packed.length));
}
return {w:320,h:200,indices:unpack4bpp(packed,want),pal,title,fmt:'MGE'};
}
/* ---- CM3 (CoCoMax III): 29-byte header, optional 243-byte pattern table,
then 1 or 2 320x192 pages. Compression unspecified -> treat literal. */
function decodeCM3(bytes){
const flags=bytes[0];
const pal=palFromBytes(bytes.subarray(1,17),16);
const twoPage=(flags>>7)&1; // bit7: 0=1 page,1=2 pages
const excludePat=flags&1; // bit0: 1=no patterns
let off=29 + (excludePat?0:243);
const rows=twoPage?384:192;
const want=320*rows;
const idx=unpack4bpp(bytes.subarray(off), want);
return {w:320,h:rows,indices:idx,pal,fmt:'CM3'};
}
/* ---- RAT (The Rat): 19-byte header (escape, compflag, 16 cmap, bg),
320x199 4bpp, escape-code RLE. ---- */
function decodeRAT(bytes){
const esc=bytes[0], compFlag=bytes[1];
const pal=palFromBytes(bytes.subarray(2,18),16);
const want=320*199, packed=new Uint8Array(Math.ceil(want/2));
const data=bytes.subarray(19);
if(compFlag!==0){
let o=0,i=0;
while(i<data.length && o<packed.length){
const n=data[i++];
if(n===esc){ const r=data[i++], v=data[i++]; for(let k=0;k<r&&o<packed.length;k++) packed[o++]=v; }
else packed[o++]=n;
}
} else packed.set(data.subarray(0,packed.length));
return {w:320,h:199,indices:unpack4bpp(packed,want),pal,fmt:'RAT'};
}
/* ---- VEF (OS-9): 18-byte header, type table, optional "squashed" RLE ---- */
const VEF_TYPES=[ {w:320,h:200,bpp:4},{w:640,h:200,bpp:2},{w:160,h:200,bpp:4},
{w:320,h:200,bpp:2},{w:640,h:200,bpp:1} ];
function decodeVEF(bytes){
const flags=bytes[0], type=bytes[1];
const t=VEF_TYPES[type]||VEF_TYPES[0];
const pal=palFromBytes(bytes.subarray(2,18),16);
const bytesPerRow=Math.ceil(t.w*t.bpp/8);
const total=bytesPerRow*t.h;
let raw=new Uint8Array(total);
const data=bytes.subarray(18);
if(flags&0x80){
// squashed: 400 half-row blocks; each: [len][packets]; packet hdr hi-bit=RLE
let i=0,o=0;
const halfBytes=bytesPerRow/2;
for(let blk=0; blk<t.h*2 && i<data.length; blk++){
const blen=data[i++]; const end=i+blen; const rowEnd=o+halfBytes;
while(i<end && o<rowEnd){
const hdr=data[i++];
if(hdr<128){ for(let k=0;k<hdr && o<raw.length;k++) raw[o++]=data[i++]; }
else { const v=data[i++]; for(let k=0;k<hdr-128 && o<raw.length;k++) raw[o++]=v; }
}
i=end; o=rowEnd;
}
} else raw.set(data.subarray(0,total));
let idx;
if(t.bpp===4) idx=unpack4bpp(raw,t.w*t.h);
else if(t.bpp===2) idx=unpack2bpp(raw,t.w*t.h);
else idx=unpack1bpp(raw,t.w*t.h);
return {w:t.w,h:t.h,indices:idx,pal,fmt:'VEF'};
}
function decodeCoco3(name,bytes){
const ext=(name.split('.').pop()||'').toUpperCase();
if(ext==='HRS') return decodeHRS(bytes);
if(ext==='MGE') return decodeMGE(bytes);
if(ext==='CM3') return decodeCM3(bytes);
if(ext==='RAT') return decodeRAT(bytes);
if(ext==='VEF') return decodeVEF(bytes);
// sniff by length
if(bytes.length===320*192/2+16) return decodeHRS(bytes);
return null;
}
/* ===================== 16-color -> artifact-pattern mapping =====================
To bring a CoCo3 (or PNG) image into the CoCo1 artifact world, each source
color is assigned one PALETTE entry (a black/white/blue/orange solid or a
dither pattern). We auto-pick a best-fit pattern by matching the pattern's
*perceived artifact color* to the source color, then let the user override. */
/* Perceived RGB of a pattern under the artifact shader (average of cells). */
function patternPerceivedRGB(tile){
let r=0,g=0,b=0,n=0;
for(let y=0;y<8;y++) for(let x=0;x<8;x+=2){
const hi=(tile[y]>>(7-x))&1, lo=(tile[y]>>(7-(x+1)))&1;
let c;
if(hi&&lo) c=COL.white; else if(hi&&!lo) c=COL.blue;
else if(!hi&&lo) c=COL.orange; else c=COL.black;
r+=c[0];g+=c[1];b+=c[2];n++;
}
return [r/n,g/n,b/n];
}
let PAT_PERCEIVED=null;
function ensurePerceived(){ if(!PAT_PERCEIVED) PAT_PERCEIVED=PALETTE.map(patternPerceivedRGB); }
function bestPatternFor(rgb){
ensurePerceived();
let best=0,bd=1e18;
for(let i=0;i<PAT_PERCEIVED.length;i++){
const p=PAT_PERCEIVED[i];
const d=(p[0]-rgb[0])**2+(p[1]-rgb[1])**2+(p[2]-rgb[2])**2;
if(d<bd){bd=d;best=i;}
}
return best;
}
/* Build an auto colour->pattern map for a decoded indexed image.
Returns {colors:[{index,rgb,count}], map:{srcIndex->paletteIndex}}. */
function buildColorMap(img){
const counts=new Map();
for(let i=0;i<img.indices.length;i++){ const c=img.indices[i]; counts.set(c,(counts.get(c)||0)+1); }
const colors=[...counts.keys()].sort((a,b)=>a-b).map(ci=>({index:ci, rgb:img.pal[ci], count:counts.get(ci)}));
const map={};
colors.forEach(c=> map[c.index]=bestPatternFor(c.rgb));
return {colors, map};
}
/* Rasterize a decoded image into a new layer using a color->pattern map.
The image is scaled (nearest) to fit 256x192, centered. Each source pixel's
color maps to a pattern; the pattern bit at that (x,y) sets the 1bpp pixel. */
function rasterizeToLayer(img, map, layerName){
const L=makeLayer(layerName||img.fmt+' image', false);
// scale to fit within 256x192 preserving aspect
const sw=img.w, sh=img.h;
const scale=Math.min(W/sw, H/sh);
const dw=Math.round(sw*scale), dh=Math.round(sh*scale);
const ox=Math.floor((W-dw)/2), oy=Math.floor((H-dh)/2);
for(let dy=0;dy<dh;dy++){
const syy=Math.min(sh-1, Math.floor(dy/scale));
for(let dx=0;dx<dw;dx++){
const sxx=Math.min(sw-1, Math.floor(dx/scale));
const ci=img.indices[syy*sw+sxx];
const tile=PALETTE[map[ci]] || TILE_BLACK;
const X=ox+dx, Y=oy+dy;
const bit=(tile[Y&7]>>(7-(X&7)))&1;
const i=Y*W+X;
L.bmp[i]=bit; L.mask[i]=1; // opaque where the image covers
}
}
return L;
}
/* ===================== PNG -> indexed (<=16 colors) =====================
Median-cut quantization to <=16 colors, then routed through the same
color->pattern mapping pipeline as the CoCo3 formats. */
function quantize16(rgbaData, w, h){
// collect pixels
const px=[]; for(let i=0;i<w*h;i++){ px.push([rgbaData[i*4],rgbaData[i*4+1],rgbaData[i*4+2]]); }
// median cut into <=16 boxes
let boxes=[{px:px}];
while(boxes.length<16){
// pick box with largest range
let bi=-1,brange=-1,bchan=0;
boxes.forEach((bx,k)=>{
if(bx.px.length<2) return;
for(let c=0;c<3;c++){
let mn=255,mx=0; for(const p of bx.px){ if(p[c]<mn)mn=p[c]; if(p[c]>mx)mx=p[c]; }
const r=mx-mn; if(r>brange){brange=r;bi=k;bchan=c;}
}
});
if(bi<0) break;
const bx=boxes[bi]; bx.px.sort((a,b)=>a[bchan]-b[bchan]);
const mid=bx.px.length>>1;
const b1={px:bx.px.slice(0,mid)}, b2={px:bx.px.slice(mid)};
boxes.splice(bi,1,b1,b2);
}
const pal=boxes.map(bx=>{
if(!bx.px.length) return [0,0,0];
let r=0,g=0,b=0; for(const p of bx.px){r+=p[0];g+=p[1];b+=p[2];}
const n=bx.px.length; return [Math.round(r/n),Math.round(g/n),Math.round(b/n)];
});
while(pal.length<16) pal.push([0,0,0]);
// map each pixel to nearest palette entry
const indices=new Uint8Array(w*h);
for(let i=0;i<w*h;i++){
const r=rgbaData[i*4],g=rgbaData[i*4+1],b=rgbaData[i*4+2];
let bk=0,bd=1e18;
for(let k=0;k<16;k++){const p=pal[k];const d=(p[0]-r)**2+(p[1]-g)**2+(p[2]-b)**2;if(d<bd){bd=d;bk=k;}}
indices[i]=bk;
}
return {w,h,indices,pal,fmt:'PNG'};
}
/* ===================== RS-DOS JV1 .DSK ===================== */
const DSK_SECTOR=256,DSK_SPT=18,DSK_TRACKS=35,DSK_DIRTRACK=17,DSK_GRANSEC=9,DSK_GRANB=DSK_GRANSEC*DSK_SECTOR;
function dskGranOffset(g){ let track=Math.floor(g/2); if(track>=DSK_DIRTRACK) track++; const sector=1+(g%2)*DSK_GRANSEC; return track*DSK_SPT*DSK_SECTOR+(sector-1)*DSK_SECTOR; }
function makeDsk(files){
const disk=new Uint8Array(DSK_TRACKS*DSK_SPT*DSK_SECTOR).fill(0xFF);
const gatOff=DSK_DIRTRACK*DSK_SPT*DSK_SECTOR+1*DSK_SECTOR;
for(let i=0;i<68;i++) disk[gatOff+i]=0xFF;
for(let s=3;s<=11;s++){const o=DSK_DIRTRACK*DSK_SPT*DSK_SECTOR+(s-1)*DSK_SECTOR;for(let i=0;i<DSK_SECTOR;i++)disk[o+i]=0xFF;}
// capacity check: total granules must fit in 68, files in 72 dir entries
const totalGrans=files.reduce((a,f)=>a+Math.max(1,Math.ceil(f.bytes.length/DSK_GRANB)),0);
if(totalGrans>68) throw new Error('DISK_FULL');
if(files.length>72) throw new Error('DIR_FULL');
let nextGran=0,dirEntry=0;
for(const f of files){
const gransNeeded=Math.max(1,Math.ceil(f.bytes.length/DSK_GRANB));
const startGran=nextGran; let written=0;
for(let g=0;g<gransNeeded;g++){
const gran=nextGran++;
if(gran>=68) throw new Error('DISK_FULL');
disk.set(f.bytes.slice(written,written+DSK_GRANB), dskGranOffset(gran)); written+=DSK_GRANB;
if(g<gransNeeded-1){ disk[gatOff+gran]=nextGran; }
else{ const lastBytes=f.bytes.length-(gransNeeded-1)*DSK_GRANB; disk[gatOff+gran]=0xC0|Math.max(1,Math.ceil(lastBytes/DSK_SECTOR)); }
}
const secIdx=Math.floor(dirEntry/8),inSec=dirEntry%8;
const eo=DSK_DIRTRACK*DSK_SPT*DSK_SECTOR+(3-1+secIdx)*DSK_SECTOR+inSec*32; dirEntry++;
const nm=(f.name+' ').slice(0,8),ex=(f.ext+' ').slice(0,3);
for(let i=0;i<8;i++) disk[eo+i]=nm.charCodeAt(i);
for(let i=0;i<3;i++) disk[eo+8+i]=ex.charCodeAt(i);
// file type: 0=BASIC, 1=BASIC data, 2=machine-code/binary, 3=text editor
// ascii flag: 0x00=binary token, 0xFF=ASCII
const ftype=(f.ftype!=null)?f.ftype:2;
const ascii=(f.ascii!=null)?f.ascii:0x00;
disk[eo+11]=ftype; disk[eo+12]=ascii; disk[eo+13]=startGran;
const lastBytes=f.bytes.length-(gransNeeded-1)*DSK_GRANB;
const rem=lastBytes-Math.floor(lastBytes/DSK_SECTOR)*DSK_SECTOR;
disk[eo+14]=(rem>>8)&0xFF; disk[eo+15]=rem&0xFF;
}
return disk;
}
function dskListFiles(disk){
const out=[];
for(let s=3;s<=11;s++){
const so=DSK_DIRTRACK*DSK_SPT*DSK_SECTOR+(s-1)*DSK_SECTOR;
for(let i=0;i<DSK_SECTOR;i+=32){
const first=disk[so+i]; if(first===0x00||first===0xFF) continue;
let name='';for(let k=0;k<8;k++){const ch=disk[so+i+k];if(ch>=32&&ch<127)name+=String.fromCharCode(ch);}
let ext='';for(let k=0;k<3;k++){const ch=disk[so+i+8+k];if(ch>=32&&ch<127)ext+=String.fromCharCode(ch);}
out.push({name:name.trim(),ext:ext.trim(),ftype:disk[so+i+11],firstGran:disk[so+i+13],lastSecBytes:(disk[so+i+14]<<8)|disk[so+i+15]});
}
}
return out;
}
function dskReadFile(disk,entry){
const gatOff=DSK_DIRTRACK*DSK_SPT*DSK_SECTOR+1*DSK_SECTOR;
let g=entry.firstGran; const buf=[]; let guard=0;
while(guard++<200){
const v=disk[gatOff+g], off=dskGranOffset(g);
if(v>=0xC0){ const used=v&0x3F; let take=used*DSK_SECTOR; if(entry.lastSecBytes>0&&used>0) take=(used-1)*DSK_SECTOR+entry.lastSecBytes; for(let k=0;k<take;k++) buf.push(disk[off+k]); break; }
else { for(let k=0;k<DSK_GRANB;k++) buf.push(disk[off+k]); g=v; }
}
return Uint8Array.from(buf);
}
/* Total/used/free granules on a 35-track RS-DOS disk (68 granules total). */
function dskUsage(disk){
const gatOff=DSK_DIRTRACK*DSK_SPT*DSK_SECTOR+1*DSK_SECTOR;
let used=0; const total=68;
for(let g=0;g<total;g++){ if(disk[gatOff+g]!==0xFF) used++; }
return {total, used, free:total-used, granBytes:DSK_GRANB};
}
/* Decode a disk file's raw bytes into either a PMODE-4 page (CoCo Max .MAX/.BIN)
or an indexed CoCo3 image (MGE/HRS/CM3/RAT/VEF). Returns {kind,page|img}. */
function decodeDiskFile(name, ext, bytes){
const e=(ext||'').toUpperCase();
if(['MGE','HRS','CM3','RAT','VEF'].includes(e)){
const img=decodeCoco3(name+'.'+e, bytes); if(img) return {kind:'img', img};
}
// CoCo Max II ".MAC" native format: a title header then a compressed screen
// marked by the "PNTGMPNT" signature. The compression scheme is proprietary
// and not reliably documented, so flag it rather than render noise.
if(e==='MAC' || isMacFile(bytes)) return {kind:'mac'};
// MAX / BIN and anything LOADM-wrapped -> PMODE 4 page
if(bytes[0]===0x00 && (bytes[1]===0x18 || (bytes[1]===0x48))){ // 0x1800=6144 or 0x4800
try{ const r=readLoadm(bytes); return {kind:'page', page:r.page}; }catch(_){}
}
if(bytes[0]===0x00){
try{ const r=readLoadm(bytes); if(r.page) return {kind:'page', page:r.page}; }catch(_){}
}
// raw 6144 page
if(bytes.length>=PAGE_BYTES) return {kind:'page', page:bytes.slice(0,PAGE_BYTES)};
return null;
}
/* Detect the CoCo Max II MAC signature "PNTGMPNT" within the header region. */
function isMacFile(bytes){
const sig=[0x50,0x4E,0x54,0x47,0x4D,0x50,0x4E,0x54];
const lim=Math.min(bytes.length-8, 80);
for(let o=0;o<lim;o++){
let ok=true; for(let k=0;k<8;k++){ if(bytes[o+k]!==sig[k]){ok=false;break;} }
if(ok) return true;
}
return false;
}
/* Build a fresh disk image from an array of {name,ext,bytes}. */
function rebuildDsk(files){ return makeDsk(files.map(f=>({name:f.name,ext:f.ext,bytes:f.bytes,ftype:f.ftype,ascii:f.ascii}))); }
/* Generate a CoCo Disk BASIC loader (ASCII format) that loads a PMODE 4 image
and displays it. Stored as ASCII BASIC (type 0, ascii flag 0xFF), which
RS-DOS LOADs and RUNs. Uses the same base name as the binary it loads. */
function makeBasicLoader(binName){
const nm=binName.toUpperCase().slice(0,8);
const prog =
'10 PMODE4,1\r'+
'20 PCLS\r'+
'30 SCREEN1,1\r'+
'40 LOADM"'+nm+'"\r'+
'50 GOTO50\r';
const bytes=new Uint8Array(prog.length);
for(let i=0;i<prog.length;i++) bytes[i]=prog.charCodeAt(i)&0xFF;
return bytes;
}
/* ===================== ASM / BIN / RLE export ===================== */
function pageToAsm(page,name){
let s=`* ${name} - PMODE 4 screen (256x192, 6144 bytes)\n`;
s+=` ORG $0E00\n`;
for(let y=0;y<H;y++){
for(let bx=0;bx<BPR;bx+=8){
const row=[]; for(let k=0;k<8;k++) row.push('$'+page[y*BPR+bx+k].toString(16).toUpperCase().padStart(2,'0'));
s+=` FCB ${row.join(',')}\n`;
}
}
return s;
}
function pageToRLE(page){
// simple RLE: count,value byte pairs over the raw page
const out=[]; let i=0;
while(i<page.length){ let v=page[i],n=1; while(i+n<page.length && page[i+n]===v && n<255) n++; out.push(n,v); i+=n; }
return Uint8Array.from(out);
}
/* ===================== JSON project (layers) ===================== */
function bmpToB64(u8){ let s=''; for(let i=0;i<u8.length;i++) s+=String.fromCharCode(u8[i]); return btoa(s); }
function b64ToBmp(b64){ const s=atob(b64); const u=new Uint8Array(s.length); for(let i=0;i<s.length;i++) u[i]=s.charCodeAt(i); return u; }
function projectToJSON(title){
return JSON.stringify({
app:'CoCo Max Online', version:1, w:W, h:H, title:title||'untitled',
cssBit, dispMode, activeLayer,
layers: layers.map(L=>({ name:L.name, visible:L.visible, bmp:bmpToB64(L.bmp), mask:bmpToB64(L.mask) }))
});
}
function projectFromJSON(text){
const j=JSON.parse(text);
layers = j.layers.map(o=>({ id:layerSeq++, name:o.name, visible:o.visible, bmp:b64ToBmp(o.bmp), mask:b64ToBmp(o.mask) }));
activeLayer=Math.min(j.activeLayer||0, layers.length-1);
cssBit=j.cssBit||0; dispMode=j.dispMode||'composite';
return j.title||'untitled';
}
/* ===================== Tools & drawing interaction ===================== */
let tool='pencil';
let gridOn=false, snapGrid=false, gridW=8, gridH=8;
let rulersOn=false, RULER_H=64, RULER_V=48; /* ruler ticks: 64px horiz, 48px vert */
let fatBits=false, fbCols=32, fbRows=24, fbOX=0, fbOY=0;
let selRect=null; // {x0,y0,x1,y1} in bitmap coords
let selFloat=null; // floating selection {x,y,w,h,bmp,mask}
let antsPhase=0;
const TOOLS=[
['lasso','marquee'],['hand','text'],['bucket','spray'],['brush','pencil'],
['line','eraser'],['rect','rectf'],['rrect','rrectf'],['oval','ovalf'],
['free','freef'],['poly','polyf'],
['dropper','wand'],['crop','resize']
];
function normRect(r){return {x0:Math.min(r.x0,r.x1),y0:Math.min(r.y0,r.y1),x1:Math.max(r.x0,r.x1),y1:Math.max(r.y0,r.y1)};}
function snap(v,step){ return snapGrid? Math.round(v/step)*step : v; }
let undoStack=[], redoStack=[];
function snapshotLayers(){ return layers.map(L=>({name:L.name,visible:L.visible,bmp:L.bmp.slice(),mask:L.mask.slice(),id:L.id})); }
function pushUndo(){
undoStack.push(snapshotLayers());
if(undoStack.length>30) undoStack.shift();
redoStack=[]; // any new edit invalidates redo
updateEditMenu&&updateEditMenu();
}
function doUndo(){
if(!undoStack.length) return;
redoStack.push(snapshotLayers());
layers=undoStack.pop(); if(activeLayer>=layers.length) activeLayer=layers.length-1;
buildLayerList(); render(); updateEditMenu&&updateEditMenu();
}
function doRedo(){
if(!redoStack.length) return;
undoStack.push(snapshotLayers());
layers=redoStack.pop(); if(activeLayer>=layers.length) activeLayer=layers.length-1;
buildLayerList(); render(); updateEditMenu&&updateEditMenu();
}
function line(x0,y0,x1,y1,thick){
x0=Math.round(x0);y0=Math.round(y0);x1=Math.round(x1);y1=Math.round(y1);
let dx=Math.abs(x1-x0),dy=Math.abs(y1-y0),sx=x0<x1?1:-1,sy=y0<y1?1:-1,err=dx-dy;
while(true){ thick?plotThick(x0,y0):plot(x0,y0); if(x0===x1&&y0===y1)break; const e2=2*err; if(e2>-dy){err-=dy;x0+=sx;} if(e2<dx){err+=dx;y0+=sy;} }