PageSourceSearch

https://retroapplejs.github.io/res/EMU_apple2io.js

js retroapplejs.github.io collected 2026-10-03 09:51:08 UTC 154,542 bytes, 4,451 lines download raw bytes

1//
2// Copyright (c) 2014 Thomas Skibo.
3// All rights reserved.
4//
5// Adapted in 2022 by Freddy Vandriessche.
6// notice: https://raw.githubusercontent.com/RetroAppleJS/RetroAppleJS.github.io/main/LICENSE.md
7//
8// apple2io.js
9
10// oEMU.component.IO is only a dataset referencing to the peripherals, but it should not contain the Apple2IO ojbject, instead hw.io owns the instance of Apple2IO like hw.io.<method> !!!
11if(oEMU===undefined) var oEMU = {"component":{"IO":{"ACTION_MAP":[]}},"system":{"A2P":{"active":true}}}
12
13if(oEMUI===undefined) var oEMUI = {"slotConfig":function(){},"slotsRender":function(){},"deviceBtn":function(){}} // allow tools to include apple2io.js without apple2main.js
14
15
16
17/*
18 * Shared removable-media row used by Disk II drives and SmartPort disk units.
19 * Keep the native <input type="file"> visible: the browser then owns filename
20 * presentation instead of each peripheral inventing a parallel label/state UI.
21 */
22function EMU_deviceMediaRowHTML(spec)
23{
24    spec = spec || {};
25
26    function attr(value)
27    {
28        return String(value==null ? "" : value)
29            .replace(/&/g,"&amp;")
30            .replace(/</g,"&lt;")
31            .replace(/>/g,"&gt;")
32            .replace(/\"/g,"&quot;");
33    }
34
35    var label = String(spec.label || "Disk");
36    var buttonTitle = spec.buttonTitle || (label+": no disk");
37    var downloadTitle = spec.downloadTitle || "Save disk";
38    var managedFilename = spec.fileDisplayName!==undefined && spec.fileDisplayName!==null;
39    var fileID = String(spec.fileID || "");
40    var fileControl = "";
41
42    if(managedFilename)
43    {
44        fileControl += "        <input type=\"file\""
45            + (spec.fileName ? " name=\""+attr(spec.fileName)+"\"" : "")
46            + (fileID ? " id=\""+attr(fileID)+"\"" : "")
47            + " style=\"display:none\""
48            + (spec.fileAccept ? " accept=\""+attr(spec.fileAccept)+"\"" : "")
49            + (spec.fileOnChange ? " onchange=\""+spec.fileOnChange+"\"" : "")
50            + ">";
51        if(fileID)
52            fileControl += "        <label class=appbut for=\""+attr(fileID)+"\" style=\"display:inline-block;cursor:pointer\">Choose File</label>";
53        fileControl += "        <span"
54            + (fileID ? " id=\""+attr(fileID+"_name")+"\"" : "")
55            + " style=\"padding-left:6px\">"+attr(spec.fileDisplayName)+"</span>";
56    }
57    else
58    {
59        fileControl += "        <input type=\"file\""
60            + (spec.fileName ? " name=\""+attr(spec.fileName)+"\"" : "")
61            + (fileID ? " id=\""+attr(fileID)+"\"" : "")
62            + " style=\"display:inline-block\""
63            + (spec.fileAccept ? " accept=\""+attr(spec.fileAccept)+"\"" : "")
64            + (spec.fileOnChange ? " onchange=\""+spec.fileOnChange+"\"" : "")
65            + ">";
66    }
67
68    return ""
69        + "    <div class=appbut style=\"padding:5px 0px 0px 0px;text-align:left;\">"
70        + "      <input type=button method=get class=appbut"
71        + (spec.buttonID ? " id=\""+attr(spec.buttonID)+"\"" : "")
72        + " value=\""+attr(label)+"\""
73        + " data-empty=\""+attr(spec.buttonDataEmpty || label)+"\" data-loaded=\"\""
74        + " title=\""+attr(buttonTitle)+"\""
75        + (spec.buttonOnClick ? " onclick=\""+spec.buttonOnClick+"\"" : "")
76        + (spec.buttonOnMouseOver ? " onmouseover=\""+spec.buttonOnMouseOver+"\"" : "")
77        + (spec.buttonOnMouseOut ? " onmouseout=\""+spec.buttonOnMouseOut+"\"" : "")
78        + ">"
79        + "      <form action=\"index.html\""
80        + (spec.formID ? " id=\""+attr(spec.formID)+"\"" : "")
81        + " style=\"display:inline;\">"
82        + fileControl
83        + "      </form>"
84        + "      <span style=\"float:right;white-space:nowrap\">"
85        + "<button class=appbut value=\"Download\""
86        + (spec.downloadID ? " id=\""+attr(spec.downloadID)+"\"" : "")
87        + (spec.downloadDisabled ? " disabled" : (spec.downloadOnClick ? " onclick=\""+spec.downloadOnClick+"\"" : ""))
88        + " title=\""+attr(downloadTitle)+"\">"
89        + "<i class=\"fa fa-cloud-download-alt\"></i></button>"
90        + (function()
91          {
92              var out="";
93              var actions=Array.isArray(spec.capabilityActions) ? spec.capabilityActions : [];
94              for(var i=0;i<actions.length;i++)
95              {
96                  var action=actions[i] || {};
97                  out += "<button class=\"appbut\""
98                      + (action.id ? " id=\""+attr(action.id)+"\"" : "")
99                      + (action.disabled ? " disabled" : (action.onClick ? " onclick=\""+action.onClick+"\"" : ""))
100                      + " title=\""+attr(action.title || "")+"\">"
101                      + "<i class=\""+attr(action.icon || "fa fa-circle")+"\"></i></button>";
102              }
103              return out;
104          })()
105        + "</span>"
106        + "    </div>";
107}
108
109function Apple2IO(vid,hostHardware)
110{
111    const bDebug = true;
112    var io = this;
113    hostHardware = hostHardware || null;
114
115    this.slot_ctx = {};
116    this.slots = [];
117    this.attachments = {};
118
119    /*
120     * Device hook lists are rebuilt only when attachments or their active
121     * state change.  The CPU hot path then performs direct callback calls
122     * without rescanning attachments or testing method types.
123     */
124    var tickCallbacks = [];
125    var cycleCallbacks = [];
126
127    /*
128     * Monotonic emulated CPU-cycle timebase.  This advances only when a 6502
129     * tick actually completes; it is deliberately independent of host RTC.
130     * Slow peripherals can sample it lazily instead of adding another callback
131     * to the one-call-per-CPU-tick hot path.
132     */
133    var clockTicks = 0;
134
135    function hookActive(device,hook)
136    {
137        var predicate = hook=="tick" ? device.isTickActive : device.isCycleActive;
138        return typeof(predicate)!="function" || predicate.call(device)!==false;
139    }
140
141    function rebuildDeviceHooks()
142    {
143        tickCallbacks.length = 0;
144        cycleCallbacks.length = 0;
145
146        for(var key in io.attachments)
147        {
148            var device = io.attachments[key].device;
149            if(!device) continue;
150
151            if(typeof(device.tick)=="function" && hookActive(device,"tick"))
152                tickCallbacks.push(device.tick.bind(device));
153
154            if(typeof(device.cycle)=="function" && hookActive(device,"cycle"))
155                cycleCallbacks.push(device.cycle.bind(device));
156        }
157    }
158
159    this.refreshDeviceHooks = rebuildDeviceHooks;    
160
161    // restart() installs the real empty-bus filler; live remounts reuse it.
162    var refillEmptyIOActions = function(){};
163
164    if(typeof(oEMU.component.IO)!="undefined")
165    {
166        oEMU.component.IO.self = this;
167    }
168
169
170//     _____      __  ___          _       _______  _____  
171//    |_   _|    / /.'   `.       / \     |_   __ \|_   _| 
172//      | |     / //  .-.  \     / _ \      | |__) | | |   
173//      | |    / / | |   | |    / ___ \     |  ___/  | |   
174//     _| |_  / /  \  `-'  /  _/ /   \ \_  _| |_    _| |_  
175//    |_____|/_/    `.___.'  |____| |____||_____|  |_____| 
176    
177    this.reset = function()
178    {
179        // Reset every mounted peripheral by its unique slot ownership.
180        for(var slotN in this.slots)
181        {
182            var peripheral = this.SLOT2obj(slotN);
183            if(peripheral && typeof(peripheral.reset)=="function")
184                peripheral.reset();
185        }
186
187        for(var deviceKey in this.attachments)
188        {
189            var device = this.attachments[deviceKey].device;
190            if(device && typeof(device.reset)=="function")
191                device.reset();
192        }
193
194        rebuildDeviceHooks();
195    }
196
197    this.provisionPeripheral = function(owner,model)
198    {
199        if(!owner) return false;
200
201        if(typeof(owner.IO_map)=="function")
202            mergeActionMap(CIO.ACTION_MAP,owner.IO_map(model));
203
204        var deviceConfig = Array.isArray(owner.deviceConfig) ? owner.deviceConfig : [];
205        for(var deviceN=0;deviceN<deviceConfig.length;deviceN++)
206        {
207            var deviceInfo=deviceConfig[deviceN];
208            if(!deviceInfo || deviceInfo.autoAttach===false) continue;
209
210            if(deviceInfo.autoAttach==="if-empty")
211            {
212                var mountedDevices=Array.isArray(owner.devices) ? owner.devices : [];
213                if(mountedDevices.length>0) continue;
214            }
215
216            this.attach(owner,deviceInfo);
217        }
218
219        return true;
220    }    
221
222    this.restart = function()
223    {
224        console.assert(Array.isArray(this.slots), "Apple2IO.slots must be initialized");
225
226        // create empty slot info directly in the Apple2IO-owned configuration
227        if (this.slots.length == 0)
228        {
229            for (var slotN = 0; slotN < slot_count + 1; slotN++)
230                this.slots[slotN] = {"slotTitle": slotN2name(slotN)};
231        }
232
233        var model = typeof(EMU_system_get)=="function" ? EMU_system_get() : "A2P";
234
235        update_IORANGES(model);
236        if(!CIO.ACTION_MAP) CIO.ACTION_MAP = emptyActionMap();
237
238        // _CFG_PSLOT is the whitelist; eligible IO containers are discovered.
239        var pContainers = this.scanPeripheralContainers();
240
241        // mount peripherals according to default configuration _CFG_PSLOT
242        for(var slotN in slotR.slotMap)
243        {
244            if(isNaN(slotN)) continue;
245
246            var pinfo = pContainers[slotR.slotMap[slotN][0]];
247            if(pinfo===undefined) continue;
248
249            var cinfo = _CFG_PSLOT[pinfo.PCODE];
250            var slotObj = this.slots[slotN];
251            if(slotObj===undefined) { continue; }
252
253            if(slotObj.peripheral?.mount?.source == "COM_CONFIG") continue;     // avoid re-mounting pre-configured peripherals at the next restart
254
255            var o = this.mount(cinfo,pinfo,slotN,slotR.slotFit[slotN]);
256            if(!o || !o.pObj) continue;
257
258            for(var so in o.sInfo) slotObj[so] = o.sInfo[so];
259            slotObj.peripheral = o.pObj;
260
261            if(bDebug) console.log("EMU_apple2io.js - mount(<"+pinfo.PCODE+" in "+slotN2name(slotN)+">)" );
262        }
263
264        // Peripherals may expose host maps and declarative attached devices.
265        // Re-run this on every restart, including when the mounted object is reused.
266        for(var mountedSlotN in this.slots)
267        {
268            var owner = this.slots[mountedSlotN].peripheral;
269            if(!owner) continue;
270            this.provisionPeripheral(owner,model);
271        }
272
273        refillEmptyIOActions = installEmptyIOActions;
274        installEmptyIOActions();
275
276        function installEmptyIOActions()
277        {
278            function isSafeRead(ctx) { return ctx && ctx.bRO === true; }
279
280            function rd_empty(base,label)
281            {
282                var fn = function(rel_addr,ctx)
283                {
284                    if(!isSafeRead(ctx) && bDebug)
285                        console.warn(
286                            label + ": RD unmapped address at $" +
287                            oCOM.getHexWord(0xC000 + base + rel_addr)
288                        );
289
290                    return 0x00;
291                };
292
293                fn._ioEmpty = true;
294                return fn;
295            }
296
297            function wr_empty(base,label)
298            {
299                var fn = function(rel_addr,d8,ctx)
300                {
301                    if(!isSafeRead(ctx) && bDebug)
302                        console.warn(
303                            label + ": WR unmapped address at $" +
304                            oCOM.getHexWord(0xC000 + base + rel_addr)
305                        );
306
307                    return 0x00;
308                };
309
310                fn._ioEmpty = true;
311                return fn;
312            }
313
314            /*
315            * $C000-$C07F: Host I/O.
316            * AppleBoard maps the real soft-switches; fill the remaining lines only.
317            */
318            for(var line = 0x00; line <= 0x70; line += 0x10)
319            {
320                if(!CIO.ACTION_MAP.RD[line]) CIO.ACTION_MAP.RD[line] = rd_empty(line,"HostIO");
321                if(!CIO.ACTION_MAP.WR[line]) CIO.ACTION_MAP.WR[line] = wr_empty(line,"HostIO");
322            }
323
324            /*
325            * $C080-$C0FF: Slot I/O.
326            * This fixes the observed $C090 case when slot 1 has no mounted peripheral.
327            */
328            for(var line = 0x80; line <= 0xF0; line += 0x10)
329            {
330                if(!CIO.ACTION_MAP.RD[line]) CIO.ACTION_MAP.RD[line] = rd_empty(line,"SlotIO");
331                if(!CIO.ACTION_MAP.WR[line]) CIO.ACTION_MAP.WR[line] = wr_empty(line,"SlotIO");
332            }
333
334            /*
335            * $C100-$C7FF: Slot ROM.
336            */
337            for(var line = 0x100; line <= 0x700; line += 0x100)
338            {
339                if(!CIO.ACTION_MAP.RD[line]) CIO.ACTION_MAP.RD[line] = rd_empty(line,"SlotROM");
340                if(!CIO.ACTION_MAP.WR[line]) CIO.ACTION_MAP.WR[line] = wr_empty(line,"SlotROM");
341            }
342
343            /*
344            * $C800-$CFFF: Shared expansion ROM / Host ROM area.
345            * $CFFF is the conventional "disable slot C8 ROM" address, but when no
346            * card owns this area, it must still be safely readable.
347            */
348            for(var line = 0x800; line <= 0xF00; line += 0x100)
349            {
350                if(!CIO.ACTION_MAP.RD[line]) CIO.ACTION_MAP.RD[line] = rd_empty(line,"HostROM");
351                if(!CIO.ACTION_MAP.WR[line]) CIO.ACTION_MAP.WR[line] = wr_empty(line,"HostROM");
352            }
353        }
354
355
356/*
357Example output of a mounted peripheral:
358
359this.slots[3] = 
360{
361   "slotTitle":"PR#2",
362   "peripheral":
363   {
364      "id":
365      {
366         "PCODE":"VIDEX",
367         "icon":"fa fa-tv",
368         "coID":"col80card",
369         "description":"Videx Videoterm 80 Column Display"
370      },
371      "state":{ "active":true },
372      "action":{ },
373      "mount":
374      {
375         "slotN":3,
376         "slotFit":["DISKII","VIDEX","MOCK"],
377         "ranges":
378         {
379            "HostROM":{ "from":2048, "to":4095 },
380            "SlotIO":{ "from":176, "to":191 },
381            "SlotROM":{ "from":768, "to":1023 }
382         },
383         "hash":16425
384      }
385   }
386}
387*/
388
389        //console.log(CIO.ACTION_MAP);
390        //console.group("ACTION_MAP overview");
391        //console.log("ACTION_MAP size="+oCOM.roughSizeOfObject(CIO.ACTION_MAP)+"bytes");
392        //console.table(actionMapEntryCount(CIO.ACTION_MAP));
393        //console.table(actionMapSpanReport(CIO.ACTION_MAP));
394        //console.groupEnd();
395
396        console.group("slot configuration overview");
397        console.table(slotConfigReport(this.slots));
398        //console.log("this.slots = "+JSON.stringify(this.slots));
399        console.groupEnd();
400
401        // restart all plugged-in and active peripherals
402        for(var slotN in this.slots)
403        {
404            if(this.slots[slotN].peripheral===undefined) continue;
405            var peripheral_obj = this.slots[slotN].peripheral;
406            if(peripheral_obj.restart)
407            {
408                peripheral_obj.restart();
409                if(bDebug) console.log("EMU_apple2io.js - restart(<"+peripheral_obj.id.PCODE+" in "+slotN2name(slotN)+">)" );
410            }
411        }
412
413        for(var deviceKey in this.attachments)
414        {
415            var device = this.attachments[deviceKey].device;
416            if(device && typeof(device.restart)=="function")
417            {
418                device.restart();
419                if(bDebug) console.log("EMU_apple2io.js - restart device <"+device.id.DCODE+" on "+device.id.hostPCODE+">");
420            }
421        }
422
423        rebuildDeviceHooks();
424        /*
425         * Every mounted peripheral was restarted in the slot loop above.
426         * Do not restart one arbitrarily selected PCODE match a second time.
427         */
428
429
430        //if(typeof(CIO)=="undefined") var CIO = {ACTION_MAP:{}};
431    }
432
433function mergeActionMap(dst,src)
434{
435    if(!dst) dst = emptyActionMap();
436    if(!src) return dst;
437
438    for(var op in src)
439    {
440        if(!dst[op]) dst[op] = {};
441        for(var addr in src[op])
442            dst[op][addr] = src[op][addr];
443    }
444
445    return dst;
446}
447
448    this.address_encoder = function(rel_addr,bucket,slot)
449    {
450        switch(bucket)
451        {
452            case "HostIO":  return rel_addr;
453            case "SlotIO":  return rel_addr - 0x80  - (slot<<4);
454            case "SlotROM": return rel_addr - 0x100 - (slot-1<<8);
455            case "HostROM": return rel_addr - 0x800;
456        } 
457    }
458
459    /*
460     * Type query: intentionally returns every matching instance.
461     * Never select obj_arr[0] when the caller means a particular slot.
462     */
463    this.PCODE2obj = function(PCODE)
464    {
465        var obj_arr = [];
466        for(var o in this.slots)
467        {
468            if(this.slots[o].peripheral?.id.PCODE == PCODE)
469                obj_arr.push(this.slots[o].peripheral);
470        }
471        return obj_arr;
472    }
473
474    this.DCODE2obj = function(DCODE,hostPCODE)
475    {
476        var obj_arr = [];
477
478        for(var slotN in this.slots)
479        {
480            var owner = this.slots[slotN].peripheral;
481            if(!owner || (hostPCODE && owner.id?.PCODE != hostPCODE)) continue;
482
483            var devices = Array.isArray(owner.devices) ? owner.devices : [];
484            for(var i=0;i<devices.length;i++)
485                if(devices[i]?.id?.DCODE == DCODE)
486                    obj_arr.push(devices[i]);
487        }
488
489        return obj_arr;
490    }
491
492    /*
493     * Pipe addresses identify one named port on one child device:
494     *
495     *     <slot>:<DCODE>:<port>
496     *
497     * The first live connection is:
498     *
499     *     0:PASTEBO:text  ->  0:A2KBD:text
500     *
501     * Apple2IO only resolves endpoints and validates their contracts.  Payload
502     * interpretation belongs to the destination device.
503     */
504
505    function pipeMimeParse(mime)
506    {
507        var fields = String(mime || "").split(";");
508        var base = String(fields.shift() || "").trim().toLowerCase();
509        var params = {};
510
511        for(var i=0;i<fields.length;i++)
512        {
513            var field = fields[i].trim();
514            if(!field) continue;
515
516            var eq = field.indexOf("=");
517            if(eq<1) continue;
518
519            var key = field.substring(0,eq).trim().toLowerCase();
520            var value = field.substring(eq+1).trim();
521
522            if(value.length>=2 &&
523               ((value[0]=='"' && value[value.length-1]=='"') ||
524                (value[0]=="'" && value[value.length-1]=="'")))
525                value = value.substring(1,value.length-1);
526
527            if(key=="charset") value = value.toLowerCase();
528            if(key) params[key] = value;
529        }
530
531        return {"base":base,"params":params};
532    }
533
534    function pipeMimeBase(mime)
535    {
536        return pipeMimeParse(mime).base;
537    }
538
539    function pipeMimeNormalize(mime)
540    {
541        var parsed = pipeMimeParse(mime);
542        if(!parsed.base) return "";
543
544        var keys = Object.keys(parsed.params).sort();
545        var out = parsed.base;
546
547        for(var i=0;i<keys.length;i++)
548            out += "; "+keys[i]+"="+parsed.params[keys[i]];
549
550        return out;
551    }
552
553    function pipeMimeList(port)
554    {
555        if(!port || port.mime===undefined || port.mime===null) return [];
556
557        var list = Array.isArray(port.mime) ? port.mime : [port.mime];
558        var out = [];
559
560        for(var i=0;i<list.length;i++)
561        {
562            var mime = pipeMimeNormalize(list[i]);
563            if(mime) out.push(mime);
564        }
565
566        return out;
567    }
568
569    function pipeMimeSupported(port,mime)
570    {
571        var requested = pipeMimeParse(mime);
572        if(!requested.base) return false;
573
574        var list = pipeMimeList(port);
575        var slash = requested.base.indexOf("/");
576        var major = slash>=0
577            ? requested.base.substring(0,slash)
578            : requested.base;
579
580        for(var i=0;i<list.length;i++)
581        {
582            var advertised = pipeMimeParse(list[i]);
583            var baseMatch =
584                   advertised.base=="*/*"
585                || advertised.base==requested.base
586                || advertised.base==major+"/*";
587
588            if(!baseMatch) continue;
589
590            /*
591             * Parameters advertised by a port are constraints. Thus
592             * text/plain;charset=us-ascii and text/plain;charset=utf-16le are
593             * distinct contracts, while a generic text/plain port can accept
594             * either representation.
595             */
596            var keys = Object.keys(advertised.params);
597            var paramsMatch = true;
598
599            for(var k=0;k<keys.length;k++)
600            {
601                var key = keys[k];
602                if(requested.params[key]!==advertised.params[key])
603                {
604                    paramsMatch = false;
605                    break;
606                }
607            }
608
609            if(paramsMatch) return true;
610        }
611
612        return false;
613    }
614
615    function pipeBytePayload(data)
616    {
617        if(data instanceof Uint8Array || data instanceof ArrayBuffer)
618            return true;
619
620        if(typeof(ArrayBuffer)!="undefined" &&
621           typeof(ArrayBuffer.isView)=="function" &&
622           ArrayBuffer.isView(data))
623            return true;
624
625        if(Array.isArray(data))
626        {
627            for(var i=0;i<data.length;i++)
628                if(!Number.isFinite(Number(data[i])))
629                    return false;
630            return true;
631        }
632
633        return false;
634    }
635
636    function pipeMimeCompatible(port,mime,data)
637    {
638        if(pipeMimeSupported(port,mime))
639            return true;
640
641        /*
642         * application/octet-stream denotes a representation-agnostic byte
643         * carrier. It may transparently carry a more specific semantic MIME
644         * when the actual payload is already bytes; no transcoding occurs.
645         *
646         * Example:
647         *   SPGPT  text/plain; charset=utf-16le
648         *      <-> SPSERIAL application/octet-stream
649         */
650        return (
651            pipeBytePayload(data) &&
652            pipeMimeSupported(port,"application/octet-stream")
653        );
654    }
655
656    function pipeDirectionAllows(port,flow)
657    {
658        var direction = String(port && port.direction || "").toLowerCase();
659
660        if(direction=="duplex") return true;
661        if(flow=="in")  return direction=="in"  || direction=="input";
662        if(flow=="out") return direction=="out" || direction=="output";
663
664        return false;
665    }
666
667    function pipeDevice(owner,DCODE)
668    {
669        var devices = owner && Array.isArray(owner.devices) ? owner.devices : [];
670
671        for(var i=0;i<devices.length;i++)
672            if(String(devices[i]?.id?.DCODE || "").toUpperCase()==DCODE)
673                return devices[i];
674
675        return null;
676    }
677
678    function pipeDeviceAtSlot(slotAddress,DCODE)
679    {
680        slotAddress = Number(slotAddress);
681        if(!Number.isInteger(slotAddress) || slotAddress<0) return null;
682
683        if(slotAddress==0)
684        {
685            var board = io.SLOT2obj(0);
686            var boardDevice = pipeDevice(board,DCODE);
687
688            if(boardDevice)
689                return {
690                     "slotIndex":0
691                    ,"owner":board
692                    ,"device":boardDevice
693                };
694        }
695
696        var slotIndex = slotAddress + 1;
697        var owner = io.SLOT2obj(slotIndex);
698        var device = pipeDevice(owner,DCODE);
699
700        return device
701            ? {
702                 "slotIndex":slotIndex
703                ,"owner":owner
704                ,"device":device
705              }
706            : null;
707    }
708
709    function pipeEndpointOpen(endpoint)
710    {
711        if(!endpoint || !endpoint.port) return false;
712
713        var open = endpoint.port.open;
714
715        if(open===undefined || open===null) return true;
716        if(typeof(open)=="boolean") return open;
717
718        if(typeof(open)=="string")
719        {
720            var method = endpoint.device && endpoint.device[open];
721            return typeof(method)=="function"
722                ? method.call(endpoint.device,endpoint)!==false
723                : false;
724        }
725
726        if(typeof(open)=="function")
727            return open.call(endpoint.device,endpoint)!==false;
728
729        return !!open;
730    }
731
732    this.pipeResolve = function(address)
733    {
734        var match =
735            /^(\d+):([A-Za-z0-9_-]+):([A-Za-z0-9_.-]+)$/
736            .exec(String(address || "").trim());
737
738        if(!match) return null;
739
740        var slotN = Number(match[1]);
741        var DCODE = match[2].toUpperCase();
742        var requestedPort = match[3];
743        var resolved = pipeDeviceAtSlot(slotN,DCODE);
744        if(!resolved) return null;
745        
746        var owner = resolved.owner;
747        var device = resolved.device;
748
749        if(!device.ports) return null;
750
751
752        var portName = null;
753        var requestedLower = requestedPort.toLowerCase();
754
755        for(var name in device.ports)
756        {
757            if(String(name).toLowerCase()==requestedLower)
758            {
759                portName = name;
760                break;
761            }
762        }
763
764        if(portName===null) return null;
765
766        return {
767             "address":slotN+":"+DCODE+":"+portName
768            ,"slotN":slotN
769            ,"slotIndex":resolved.slotIndex
770            ,"owner":owner
771            ,"device":device
772            ,"DCODE":DCODE
773            ,"portName":portName
774            ,"port":device.ports[portName]
775        };
776    };
777
778    this.pipeIsOpen = function(address)
779    {
780        return pipeEndpointOpen(this.pipeResolve(address));
781    }
782    /*
783     * Resolve the semantic text-input endpoint that currently owns user text.
784     *
785     * Motherboard keyboard input is the permanent fallback. An active
786     * VideoTerm takes precedence only while its VIDEXTXT:text port is open,
787     * which is already tied to the VideoTerm video-selection state.
788     *
789     * Both pasteboard text and printable host-keyboard characters use this
790     * selector so they cannot drift into separate character-mapping rules.
791     */
792    this.getTextInputTargetAddress = function()
793    {
794        const fallback = "0:A2KBD:text";
795
796        if(!Array.isArray(this.slots) ||
797           typeof(this.SLOT2obj)!="function")
798            return fallback;
799
800        for(var slotIndex=1;slotIndex<this.slots.length;slotIndex++)
801        {
802            var owner = this.SLOT2obj(slotIndex);
803
804            if(!owner ||
805               String(owner.id?.PCODE || "").toUpperCase()!="VIDEX")
806                continue;
807
808            if(owner.state && owner.state.active===false)
809                continue;
810
811            var slotID =
812                typeof(this.slot2ID)=="function"
813                    ? this.slot2ID(slotIndex)
814                    : String(slotIndex-1);
815
816            var address = slotID + ":VIDEXTXT:text";
817
818            if(this.pipeIsOpen(address))
819                return address;
820        }
821
822        return fallback;
823    };
824
825    /*
826     * Pipe availability is runtime state, not merely topology. Consumers such
827     * as the pasteboard UI can subscribe once and recompute their preferred
828     * route whenever a device reports that one of its routing conditions
829     * changed.
830     */
831    var pipeStateListeners = [];
832
833    this.subscribePipeStateChange = function(callback)
834    {
835        if(typeof(callback)!="function") return function(){};
836
837        pipeStateListeners.push(callback);
838        var subscribed = true;
839
840        return function()
841        {
842            if(!subscribed) return;
843            subscribed = false;
844
845            var index = pipeStateListeners.indexOf(callback);
846            if(index>=0) pipeStateListeners.splice(index,1);
847        };
848    };
849
850    this.notifyPipeStateChange = function(change)
851    {
852        var listeners = pipeStateListeners.slice();
853
854        for(var i=0;i<listeners.length;i++)
855        {
856            try { listeners[i](change || {}); }
857            catch(err)
858            {
859                console.error("Apple2IO pipe-state listener failed",err);
860            }
861        }
862    };
863
864    this.pipeSend = function(sourceAddress,targetAddress,message)
865    {
866        var source = this.pipeResolve(sourceAddress);
867        var target = this.pipeResolve(targetAddress);
868
869        if(!source || !target)
870        {
871            console.error(
872                "Apple2IO.pipeSend: unresolved pipe endpoint",
873                sourceAddress,
874                targetAddress
875            );
876            return false;
877        }
878
879        if(!pipeEndpointOpen(source))
880        {
881            console.error("Apple2IO.pipeSend: source port is closed",source.address);
882            return false;
883        }
884
885        if(!pipeEndpointOpen(target))
886        {
887            console.error("Apple2IO.pipeSend: target port is closed",target.address);
888            return false;
889        }
890
891        if(!pipeDirectionAllows(source.port,"out"))
892        {
893            console.error("Apple2IO.pipeSend: source port is not an output",source.address);
894            return false;
895        }
896
897        if(!pipeDirectionAllows(target.port,"in"))
898        {
899            console.error("Apple2IO.pipeSend: target port is not an input",target.address);
900            return false;
901        }
902
903        var envelope = Object.assign({},message || {});
904        var mime = pipeMimeNormalize(envelope.mime);
905
906        if(!mime)
907        {
908            var sourceMimes = pipeMimeList(source.port);
909            mime = sourceMimes.length ? sourceMimes[0] : "";
910        }
911
912        if(!pipeMimeCompatible(source.port,mime,envelope.data) ||
913           !pipeMimeCompatible(target.port,mime,envelope.data))
914        {
915            console.error(
916                "Apple2IO.pipeSend: incompatible MIME type",
917                mime,
918                source.address,
919                target.address
920            );
921            return false;
922        }
923
924        envelope.mime = mime;
925
926        var handler = target.port.handler;
927
928        if(typeof(handler)=="string")
929            handler = target.device[handler];
930
931        if(typeof(handler)!="function" && typeof(target.device.pipeReceive)=="function")
932            handler = target.device.pipeReceive;
933
934        if(typeof(handler)!="function")
935        {
936            console.error(
937                "Apple2IO.pipeSend: target port has no receiver",
938                target.address
939            );
940            return false;
941        }
942
943        var result = handler.call(
944            target.device,
945            envelope,
946            {
947                 "io":this
948                ,"hw":hostHardware
949                ,"source":source
950                ,"target":target
951            }
952        );
953
954        return result===undefined ? true : result;
955    };
956
957    /*
958     * Pull one current payload from an output endpoint and route it through the
959     * normal pipeSend() validation/delivery path.
960     *
961     * Pull-capable output ports publish:
962     *
963     *     provider: "methodName"
964     *
965     * The provider may return a complete message envelope ({mime,data,...}) or
966     * raw data. Raw data is wrapped using the first MIME advertised by source.
967     */
968    this.pipeTransfer = function(sourceAddress,targetAddress,request)
969    {
970        var source = this.pipeResolve(sourceAddress);
971        var target = this.pipeResolve(targetAddress);
972
973        if(!source || !target)
974        {
975            console.error(
976                "Apple2IO.pipeTransfer: unresolved pipe endpoint",
977                sourceAddress,
978                targetAddress
979            );
980            return false;
981        }
982
983        if(!pipeEndpointOpen(source))
984        {
985            console.error("Apple2IO.pipeTransfer: source port is closed",source.address);
986            return false;
987        }
988
989        if(!pipeEndpointOpen(target))
990        {
991            console.error("Apple2IO.pipeTransfer: target port is closed",target.address);
992            return false;
993        }
994
995        if(!pipeDirectionAllows(source.port,"out"))
996        {
997            console.error("Apple2IO.pipeTransfer: source port is not an output",source.address);
998            return false;
999        }
1000
1001        if(!pipeDirectionAllows(target.port,"in"))
1002        {
1003            console.error("Apple2IO.pipeTransfer: target port is not an input",target.address);
1004            return false;
1005        }
1006
1007        var provider = source.port.provider;
1008
1009        if(typeof(provider)=="string")
1010            provider = source.device[provider];
1011
1012        if(typeof(provider)!="function")
1013        {
1014            console.error(
1015                "Apple2IO.pipeTransfer: source port has no provider",
1016                source.address
1017            );
1018            return false;
1019        }
1020
1021        var produced = provider.call(
1022            source.device,
1023            request || {},
1024            {
1025                 "io":this
1026                ,"hw":hostHardware
1027                ,"source":source
1028                ,"target":target
1029            }
1030        );
1031
1032        if(produced===undefined || produced===null || produced===false)
1033            return false;
1034
1035        var envelope;
1036
1037        if(produced &&
1038           typeof(produced)=="object" &&
1039           !Array.isArray(produced) &&
1040           produced.mime!==undefined)
1041        {
1042            envelope = Object.assign({},produced);
1043        }
1044        else
1045        {
1046            var sourceMimes = pipeMimeList(source.port);
1047            envelope = {
1048                 "mime":sourceMimes.length ? sourceMimes[0] : ""
1049                ,"data":produced
1050            };
1051        }
1052
1053        return this.pipeSend(sourceAddress,targetAddress,envelope)
1054            ? envelope
1055            : false;
1056    };
1057
1058    function emptyActionMap()
1059    {
1060        return {"WR":{},"RD":{},"RR":{},"SV":{},"VA":{},"BT":{},"RG":{}};
1061    }
1062
1063    function unmapPeripheralActions(peripheral_obj)
1064    {
1065        var ranges = peripheral_obj && peripheral_obj.mount ? peripheral_obj.mount.ranges || {} : {};
1066        var actions = peripheral_obj && peripheral_obj.action ? peripheral_obj.action : {};
1067        var bindings = [
1068             ["HostROM","RD"]
1069            ,["HostROM","WR"]
1070            ,["SlotROM","RD"]
1071            ,["SlotROM","WR"]
1072            ,["SlotIO","RD"]
1073            ,["SlotIO","WR"]
1074            ,["HostIO","RD"]
1075            ,["HostIO","WR"]
1076        ];
1077
1078        for(var b=0;b<bindings.length;b++)
1079        {
1080            var rangeName = bindings[b][0];
1081            var op = bindings[b][1];
1082            var range = ranges[rangeName];
1083            var action = actions[rangeName] && actions[rangeName][op];
1084            var callback = action && action.callback;
1085
1086            if(!range || typeof(callback)!="function") continue;
1087
1088            for(var addr=range.from;addr<=range.to;addr++)
1089                if(CIO.ACTION_MAP[op][addr] === callback)
1090                    delete CIO.ACTION_MAP[op][addr];
1091        }
1092    }
1093
1094
1095    function unmapAttachedActions(entry)
1096    {
1097        var bindings = entry && Array.isArray(entry.bindings) ? entry.bindings : [];
1098
1099        for(var i=0;i<bindings.length;i++)
1100        {
1101            var binding = bindings[i];
1102            var map = CIO.ACTION_MAP[binding.op];
1103            if(!map || map[binding.addr] !== binding.callback) continue;
1104
1105            if(typeof(binding.previous)=="function") map[binding.addr] = binding.previous;
1106            else delete map[binding.addr];
1107        }
1108
1109        if(entry) entry.bindings = [];
1110    }
1111
1112    function update_IORANGES(sys_model)
1113    {
1114        if(oEMU.system===undefined) return;
1115        if(typeof(_CFG_IORANGES)==="undefined") return;
1116
1117        for(var o in _CFG_IORANGES)
1118        {
1119            if(syscode_has_model(o,sys_model))
1120            {
1121                oEMU.system["IORANGES"] = _CFG_IORANGES[o];
1122
1123                oEMU.system["IORANGES-SLOT"] = {};
1124                for(var r in _CFG_IORANGES[o])
1125                {
1126                    oEMU.system["IORANGES-SLOT"][r] =
1127                        oCOM.parseRngExpr(_CFG_IORANGES[o][r],{n:3});
1128                }
1129                return;
1130            }
1131        }
1132    }
1133
1134    function syscode_has_model(syscodes,sys_model)
1135    {
1136        if(!syscodes || !sys_model) return false;
1137
1138        var needle = String(sys_model).toUpperCase();
1139        var list = String(syscodes)
1140            .replace(/<br\s*\/?\s*>/gi,"")
1141            .split(",");
1142
1143        for(var i=0;i<list.length;i++)
1144        {
1145            if(oCOM.trim(list[i]).toUpperCase()===needle) return true;
1146        }
1147        return false;
1148    }
1149
1150   var CIO = oEMU.component.IO;
1151
1152
1153    function line_decode(adr)  { return adr<256 ? adr & 0xF0 : (adr & 0xFF00); } // line decoder on IO & PROM addressing
1154
1155    this.read = function(rel_addr)
1156    {
1157        var line = line_decode(rel_addr);
1158
1159        const ctx2 =
1160        {
1161            //"keys": keys,
1162            //"snd": snd,
1163            "vid": vid,
1164            "io": this,
1165            "bRO": (typeof(apple2plus) == "object" && apple2plus && apple2plus.hwObj().bRO === true),
1166            "rel_addr": rel_addr,
1167            "line": line,
1168            "abs_addr": 0xC000 + rel_addr
1169        };
1170
1171        var fn = CIO.ACTION_MAP.RD[line];
1172        if(!fn) { if(!ctx2.bRO) console.warn("CIO.ACTION_MAP.RD["+oCOM.getHexWord(line)+"] I/O call out of bounds (0x"+oCOM.getHexWord(line+0xC000)+")"); return 0x00; }
1173
1174        /*
1175        * Read-only bus scan policy:
1176        *
1177        * SlotROM / HostROM must still be read normally, because these are real
1178        * CPU-visible ROM bytes, for example Disk II card ROM.
1179        *
1180        * SlotIO reads are different: on many Apple II cards they are soft-switch
1181        * triggers. During bRO scans, do not call those callbacks.
1182        */
1183        if(ctx2.bRO && fn._ioReport && fn._ioReport.range == "SlotIO")
1184            return 0x00;
1185
1186        return fn(rel_addr-line,ctx2);
1187    }
1188
1189    this.write = function(rel_addr,d8)
1190    {
1191        var line = line_decode(rel_addr);
1192
1193        const ctx2 =
1194        {
1195            //"keys": keys,
1196            //"snd": snd,
1197            "vid": vid,
1198            "io": this,
1199            "bRO": (typeof(apple2plus) == "object" && apple2plus && apple2plus.hwObj().bRO === true),
1200            "rel_addr": rel_addr,
1201            "line": line,
1202            "abs_addr": 0xC000 + rel_addr
1203        };
1204
1205        var fn = CIO.ACTION_MAP.WR[line];
1206
1207        /*
1208        * Empty-bus WR fillers must not hide a peripheral's dynamic HostROM
1209        * write handler or an RD-style soft-switch fallback.
1210        */
1211        if(fn && fn._ioEmpty!==true)
1212            return fn(rel_addr-line,d8,ctx2);
1213
1214        /*
1215         * $C800-$CFFF is a shared expansion window. Its write target must follow
1216         * the same Hslot owner as its read target; permanently installing one
1217         * card's HostROM.WR callback would become stale as soon as another card
1218         * claims C8 space.
1219         *
1220         * Peripheral mount.slotN uses RetroAppleJS' internal +1 convention while
1221         * ACTION_MAP.Hslot stores the real Apple slot number, hence +1 here.
1222         */
1223        if(line>=0x800 && line<=0xF00 &&
1224           CIO.ACTION_MAP.Hslot!==null &&
1225           CIO.ACTION_MAP.Hslot!==undefined)
1226        {
1227            var hostOwner = this.SLOT2obj(Number(CIO.ACTION_MAP.Hslot)+1);
1228            var hostWrite =
1229                hostOwner &&
1230                hostOwner.action &&
1231                hostOwner.action.HostROM &&
1232                hostOwner.action.HostROM.WR;
1233
1234            if(hostWrite && typeof(hostWrite.callback)=="function")
1235                return hostWrite.callback(rel_addr-line,d8,ctx2);
1236        }
1237
1238        /*
1239        * Some Apple II soft-switches are triggered by the address access itself.
1240        * The 16K language card / ramcard is the important case here:
1241        * DOS 3.3 may write to $C080-$C08F, while the peripheral historically
1242        * exposed only a read-side SlotIO callback.
1243        *
1244        * Keep this fallback so write accesses can still trigger RD-style
1245        * soft-switch side effects when no explicit WR handler exists.
1246        */
1247        var rd = CIO.ACTION_MAP.RD[line];
1248        if(rd && rd._ioEmpty!==true) return rd(rel_addr-line,ctx2);
1249
1250        /*
1251        * Nothing real handled the write. Use the installed empty-WR callback
1252        * now, so diagnostics remain unchanged for genuinely unmapped writes.
1253        */
1254        if(fn) return fn(rel_addr-line,d8,ctx2);
1255        if(!ctx2.bRO) console.warn("CIO.ACTION_MAP.WR["+oCOM.getHexWord(line)+"] I/O call out of bounds (0x"+oCOM.getHexWord(line+0xC000)+")");
1256
1257        return 0x00;
1258    }
1259
1260    // CPU-tick hook for devices that need sub-cycle sampling, such as the speaker.
1261    this.tick = function(n)
1262    {
1263        clockTicks++;
1264        for(var i=0;i<tickCallbacks.length;i++)
1265            tickCallbacks[i](n);
1266    }
1267
1268    this.getClockTicks = function()
1269    {
1270        return clockTicks;
1271    }
1272
1273    // Processing-cycle hook, called once after the configured CPU-tick group.
1274    this.cycle = function()
1275    {
1276        for(var i=0;i<cycleCallbacks.length;i++)
1277            cycleCallbacks[i]();
1278
1279
1280
1281        /*
1282         * Mounted peripherals with slow independent timing domains (UARTs,
1283         * timers, etc.) can synchronize once per processing slice as well as
1284         * lazily on their own register accesses.  Passing the absolute counter
1285         * avoids loss of time when processing slices are shortened by turbo
1286         * host-capacity limits.
1287         */
1288        for(var slotN in this.slots)
1289        {
1290            var peripheral = this.slots[slotN] && this.slots[slotN].peripheral;
1291            // Mounted cards share the same processing-frame cadence as devices.
1292            // In particular, camera sampling follows the SYSTEM FPS scheduler.
1293            if(peripheral && typeof(peripheral.cycle)=="function" && hookActive(peripheral,"cycle"))
1294                peripheral.cycle();
1295            if(peripheral && typeof(peripheral.syncClock)=="function")
1296                peripheral.syncClock(clockTicks);
1297        }
1298    }
1299
1300    this.attach = function(owner,device_info,options)
1301    {
1302        if(!owner || !owner.id?.PCODE || !device_info || !device_info.coID) return null;
1303
1304        options=options || {};
1305        var newInstance=options.newInstance===true;
1306        var hostPCODE=owner.id.PCODE;
1307        var dcode=device_info.DCODE || device_info.coID;
1308        if(device_info.hostPCODE && device_info.hostPCODE != hostPCODE) return null;
1309
1310        var ownerHash=owner.mount && owner.mount.hash!==undefined
1311            ? owner.mount.hash
1312            : hostPCODE;
1313        var entry=null;
1314        var key="";
1315        var device=null;
1316        var createdInstance=false;
1317
1318        /*
1319         * Normal provisioning is idempotent: it reuses the one declarative
1320         * instance for this owner/device type.  An explicit UI attach requests
1321         * a new mounted instance instead, even when the DCODE is identical.
1322         */
1323        if(!newInstance)
1324        {
1325            for(var existingKey in this.attachments)
1326            {
1327                var existing=this.attachments[existingKey];
1328                if(existing && existing.owner===owner &&
1329                   existing.device?.id?.DCODE===dcode &&
1330                   existing.explicitInstance!==true)
1331                {
1332                    entry=existing;
1333                    key=existingKey;
1334                    device=existing.device;
1335                    break;
1336                }
1337            }
1338        }
1339
1340        if(entry) unmapAttachedActions(entry);
1341
1342        if(!device)
1343        {
1344            var Device=globalThis[device_info.coID];
1345            if(typeof(Device)!="function") return null;
1346
1347            device=new Device(device_info);
1348            createdInstance=true;
1349            if(!device.id) device.id={};
1350            if(device.id.DCODE && device.id.DCODE != dcode) return null;
1351            if(device.id.hostPCODE && device.id.hostPCODE != hostPCODE) return null;
1352
1353            /*
1354             * Device instance identity mirrors peripheral mount identity: a
1355             * stable 16-bit hash belongs to this mounted object for its lifetime.
1356             * The registry key includes it, so equal DCODEs do not collide.
1357             */
1358            var instanceHash;
1359            var attempts=0;
1360            do
1361            {
1362                instanceHash=oCOM.crc16(new TextEncoder("utf-8").encode(
1363                    String(ownerHash)+":"+dcode+":"+Math.random()+":"+attempts
1364                ));
1365                key=String(ownerHash)+":"+dcode+":"+instanceHash;
1366                attempts++;
1367            }
1368            while(this.attachments[key] && attempts<65536);
1369
1370            if(this.attachments[key]) return null;
1371            entry={
1372                 "owner":owner
1373                ,"device":device
1374                ,"info":device_info
1375                ,"bindings":[]
1376                ,"explicitInstance":newInstance
1377                ,"hash":instanceHash
1378            };
1379            this.attachments[key]=entry;
1380        }
1381
1382        device.id.DCODE=dcode;
1383        device.id.hostPCODE=hostPCODE;
1384        device.id.coID=device_info.coID;
1385        if(device_info.icon!==undefined) device.id.icon=device_info.icon;
1386        if(device_info.description!==undefined) device.id.description=device_info.description;
1387        if(device_info.deviceN!==undefined && device.id.deviceN===undefined)
1388            device.id.deviceN=Number(device_info.deviceN);
1389
1390        if(!Array.isArray(owner.devices))
1391            Object.defineProperty(owner,"devices",{
1392                 "value":[]
1393                ,"writable":true
1394                ,"configurable":true
1395                ,"enumerable":false
1396            });
1397        if(owner.devices.indexOf(device)<0) owner.devices.push(device);
1398
1399        entry.owner=owner;
1400        entry.info=device_info;
1401        entry.bindings=[];
1402
1403        var attachHash=entry.hash!==undefined
1404            ? entry.hash
1405            : device.attach && device.attach.hash!==undefined
1406                ? device.attach.hash
1407                : oCOM.crc16(new TextEncoder("utf-8").encode(key));
1408        entry.hash=attachHash;
1409        device.attach={
1410             "hostPCODE":hostPCODE
1411            ,"ownerHash":ownerHash
1412            ,"range":device_info.range || "HostIO"
1413            ,"hash":attachHash
1414            ,"actions":[]
1415        };
1416
1417        if(typeof(device.bindHost)=="function")
1418        {
1419            var rollbackHostBind=function()
1420            {
1421                if(!createdInstance) return;
1422                delete io.attachments[key];
1423                var failedIdx=owner.devices.indexOf(device);
1424                if(failedIdx>=0) owner.devices.splice(failedIdx,1);
1425            };
1426            var hostResult;
1427            try
1428            {
1429                hostResult=device.bindHost(owner);
1430            }
1431            catch(e)
1432            {
1433                rollbackHostBind();
1434                throw e;
1435            }
1436            if(hostResult===false)
1437            {
1438                rollbackHostBind();
1439                return null;
1440            }
1441        }
1442
1443        if(typeof(device.bindIO)=="function")
1444            device.bindIO(io);
1445
1446        var actionMap=device_info.action || {};
1447        for(var op in actionMap)
1448        {
1449            op=String(op).toUpperCase();
1450            if(!CIO.ACTION_MAP[op]) continue;
1451
1452            for(var address in actionMap[op])
1453            {
1454                var addr=Number(address);
1455                var handler=actionMap[op][address];
1456                var method=typeof(handler)=="string"
1457                    ? device[handler]
1458                    : (handler && typeof(handler.handler)=="string"
1459                        ? device[handler.handler]
1460                        : (handler && handler.callback ? handler.callback : handler));
1461                var allowReadOnly=!!(
1462                    handler && typeof(handler)=="object" && handler.readOnly===true
1463                );
1464
1465                if(!Number.isInteger(addr) || typeof(method)!="function") continue;
1466
1467                var callback=function(target,fn,readOnly,writeAction)
1468                {
1469                    if(writeAction)
1470                    {
1471                        return function(rel_addr,d8,ctx)
1472                        {
1473                            if(ctx && ctx.bRO===true && !readOnly) return 0x00;
1474                            var result=fn.call(target,rel_addr,d8,ctx);
1475                            return result===undefined ? 0x00 : result;
1476                        };
1477                    }
1478
1479                    return function(rel_addr,ctx)
1480                    {
1481                        if(ctx && ctx.bRO===true && !readOnly) return 0x00;
1482                        var result=fn.call(target,rel_addr,ctx);
1483                        return result===undefined ? 0x00 : result;
1484                    };
1485                }(device,method,allowReadOnly,op=="WR");
1486
1487                callback._ioReport={
1488                     "DCODE":dcode
1489                    ,"hostPCODE":hostPCODE
1490                    ,"slotTitle":owner.mount ? slotN2name(owner.mount.slotN) : "attached"
1491                    ,"range":device.attach.range
1492                    ,"op":op
1493                    ,"hash":attachHash
1494                };
1495
1496                var previous=CIO.ACTION_MAP[op][addr];
1497                CIO.ACTION_MAP[op][addr]=callback;
1498                entry.bindings.push({"op":op,"addr":addr,"callback":callback,"previous":previous});
1499                device.attach.actions.push({"op":op,"addr":addr,"handler":typeof(handler)=="string" ? handler : (handler.handler || method.name || "callback")});
1500            }
1501        }
1502
1503        if(device_info.alias && oEMU.component && oEMU.component.IO)
1504            Object.defineProperty(oEMU.component.IO,device_info.alias,{
1505                 "value":device
1506                ,"writable":true
1507                ,"configurable":true
1508                ,"enumerable":false
1509            });
1510
1511        Object.defineProperty(device,"_ioRefreshHooks",{
1512             "value":rebuildDeviceHooks
1513            ,"writable":true
1514            ,"configurable":true
1515            ,"enumerable":false
1516        });
1517
1518        Object.defineProperty(device,"_ioPipeStateChanged",{
1519             "value":function(change)
1520             {
1521                 io.notifyPipeStateChange(Object.assign({
1522                      "type":"device-state"
1523                     ,"DCODE":device.id?.DCODE || ""
1524                     ,"instanceID":device.attach?.hash
1525                 },change || {}));
1526             }
1527            ,"writable":true
1528            ,"configurable":true
1529            ,"enumerable":false
1530        });
1531
1532        rebuildDeviceHooks();
1533
1534        this.notifyPipeStateChange({
1535             "type":"attach"
1536            ,"DCODE":dcode
1537            ,"hostPCODE":hostPCODE
1538            ,"instanceID":attachHash
1539        });
1540
1541        if(newInstance && typeof(owner.onDeviceTopologyChanged)==="function")
1542        {
1543            try
1544            {
1545                owner.onDeviceTopologyChanged({
1546                     "type":"attach"
1547                    ,"DCODE":dcode
1548                    ,"device":device
1549                    ,"instanceID":attachHash
1550                });
1551            }
1552            catch(e) { console.error("Device topology attach notification failed",e); }
1553        }
1554
1555        if(bDebug)
1556            console.log("EMU_apple2io.js - attach(<"+dcode+" #"+oCOM.getHexWord(attachHash)+" to "+hostPCODE+">)");
1557
1558        return device;
1559    }
1560
1561    this.detach = function(owner,DCODE,instanceHash)
1562    {
1563        if(!owner) return false;
1564        var removed = false;
1565
1566        for(var key in this.attachments)
1567        {
1568            var entry = this.attachments[key];
1569            var device = entry.device;
1570            if(entry.owner !== owner || (DCODE && device?.id?.DCODE != DCODE)) continue;
1571            if(instanceHash!==undefined && instanceHash!==null &&
1572               Number(device?.attach?.hash)!==Number(instanceHash)) continue;
1573
1574            /*
1575             * Let a child release any host-owned state before the generic
1576             * attachment record disappears.  Liron/SmartPort needs this so an
1577             * ejected UniDisk does not remain resident on the SmartPort bus.
1578             */
1579            if(device && typeof(device.unbindHost)=="function")
1580            {
1581                try
1582                {
1583                    if(device.unbindHost(owner)===false) continue;
1584                }
1585                catch(e)
1586                {
1587                    console.error("Device host detach failed",e);
1588                    continue;
1589                }
1590            }
1591
1592            unmapAttachedActions(entry);
1593            if(entry.info.alias && oEMU.component.IO[entry.info.alias] === device)
1594                delete oEMU.component.IO[entry.info.alias];
1595
1596            if(Array.isArray(owner.devices))
1597            {
1598                var idx = owner.devices.indexOf(device);
1599                if(idx>=0) owner.devices.splice(idx,1);
1600            }
1601
1602            delete this.attachments[key];
1603            removed = true;
1604
1605            if(typeof(owner.onDeviceTopologyChanged)==="function")
1606            {
1607                try
1608                {
1609                    owner.onDeviceTopologyChanged({
1610                         "type":"detach"
1611                        ,"DCODE":device?.id?.DCODE || DCODE || ""
1612                        ,"device":device
1613                        ,"instanceID":Number(device?.attach?.hash)
1614                    });
1615                }
1616                catch(e) { console.error("Device topology detach notification failed",e); }
1617            }
1618        }
1619
1620        if(removed)
1621        {
1622            rebuildDeviceHooks();
1623            this.notifyPipeStateChange({
1624                 "type":"detach"
1625                ,"DCODE":DCODE || ""
1626                ,"instanceID":instanceHash===undefined ? null : Number(instanceHash)
1627            });
1628        }
1629        return removed;
1630    }
1631
1632    this.detachInstance = function(owner,instanceHash)
1633    {
1634        instanceHash=Number(instanceHash);
1635        if(!owner || !Number.isInteger(instanceHash)) return false;
1636        return this.detach(owner,null,instanceHash);
1637    };
1638
1639    this.unmount = function(slotN)
1640    {
1641        slotN = Number(slotN);
1642        var slot = this.slots[slotN];
1643
1644        if(!Number.isInteger(slotN) || !slot || slot.lock || !slot.peripheral)
1645            return false;
1646
1647        var peripheral_obj = slot.peripheral;
1648        this.detach(peripheral_obj);
1649
1650        unmapPeripheralActions(peripheral_obj);
1651
1652        if(this.MEMORY_MAP && typeof(this.MEMORY_MAP.unmount)=="function")
1653            this.MEMORY_MAP.unmount(peripheral_obj);
1654
1655        if(typeof(peripheral_obj.onUnmount)=="function")
1656            peripheral_obj.onUnmount();
1657
1658        delete slot.peripheral;
1659        refillEmptyIOActions();
1660
1661        if(bDebug)
1662            console.log("EMU_apple2io.js - unmount(<"+peripheralPCODE(peripheral_obj)+" from "+slotN2name(slotN)+">)");
1663
1664        return true;
1665    }
1666
1667    this.mount = function(cinfo,peripheral_info,slotIdx,slotFit,peripheral_obj)
1668    {
1669        var remounting = peripheral_obj != null;
1670        var slot_info = {};
1671
1672        if(oEMU.system===undefined) return {"sInfo":slot_info,"pObj":null};
1673        if(!cinfo || !peripheral_info) return {"sInfo":slot_info,"pObj":null};
1674        if(!oEMU.system["IORANGES"]) return {"sInfo":slot_info,"pObj":null};
1675        if(!remounting && typeof(peripheral_info.ctor)!="function")
1676            return {"sInfo":slot_info,"pObj":null};
1677
1678        slotIdx = Number(slotIdx);
1679        if(!Number.isInteger(slotIdx))
1680            return {"sInfo":slot_info,"pObj":null};
1681
1682        if(Array.isArray(slotFit) && slotFit.length>0 &&
1683           slotFit.indexOf(peripheral_info.PCODE)<0)
1684            return {"sInfo":slot_info,"pObj":null};
1685
1686        if(oEMU.system["IORANGES"] && peripheral_info)
1687        {
1688            const bHostROM = typeof(oEMU.system.IORANGES.HostROM)!="undefined" && cinfo.HostROM == "X";
1689            const bSlotIO  = typeof(oEMU.system.IORANGES.SlotIO) !="undefined" && cinfo.SlotIO  == "X";
1690            const bSlotROM = typeof(oEMU.system.IORANGES.SlotROM)!="undefined" && cinfo.SlotROM == "X";
1691            const bHostIO  = typeof(oEMU.system.IORANGES.HostIO) !="undefined" && cinfo.HostIO  == "X";
1692
1693            var ranges = {};
1694            var ioBase = oCOM.parseRngExpr(oEMU.system.IORANGES.HostIO).from; // usually $C000 = base reference for the I/O address space
1695
1696            if(bHostROM) ranges.HostROM = oCOM.parseRngExpr(oEMU.system.IORANGES.HostROM,{n:slotIdx,base:ioBase});
1697            if(bSlotIO)  ranges.SlotIO  = oCOM.parseRngExpr(oEMU.system.IORANGES.SlotIO, {n:slotIdx-1,base:ioBase});
1698            if(bSlotROM) ranges.SlotROM = oCOM.parseRngExpr(oEMU.system.IORANGES.SlotROM,{n:slotIdx-1,base:ioBase});
1699            if(bHostIO)  ranges.HostIO  = oCOM.parseRngExpr(oEMU.system.IORANGES.HostIO, {n:slotIdx,base:ioBase});
1700
1701            // Reuse a live object when moving it; constructing a new one would lose device state.
1702            if(remounting) unmapPeripheralActions(peripheral_obj);
1703            else peripheral_obj = new peripheral_info.ctor();
1704            //if(cinfo!=null)  peripheral_obj.bFirstConfig = true;
1705
1706            if(peripheral_obj)
1707            {
1708                var CIO = oEMU.component.IO;
1709
1710                // Keep static identity on the peripheral object itself.
1711                if(!peripheral_obj.id) peripheral_obj.id = {};
1712                if(peripheral_obj.id.PCODE===undefined) peripheral_obj.id.PCODE = peripheral_info.PCODE;
1713                if(peripheral_obj.id.icon===undefined)  peripheral_obj.id.icon  = peripheral_info.icon || "fa fa-cube";
1714                peripheral_obj.id.coID = peripheral_info.coID || peripheral_obj.constructor.name;
1715                peripheral_obj.id.description = cinfo.NAME;
1716
1717                // Keep stable ownership metadata and dynamic mappings during a live move.
1718                var oldMount = peripheral_obj.mount || {};
1719                var mountHash = oldMount.hash;
1720                if(mountHash===undefined)
1721                    mountHash = oCOM.crc16(new TextEncoder("utf-8").encode(Math.random()));
1722
1723                peripheral_obj.mount = Object.assign({},oldMount,{
1724                     "slotN":slotIdx
1725                    ,"slotFit":slotFit || []
1726                    ,"ranges":ranges
1727                    ,"hash":mountHash
1728                    ,"source":oldMount.source || (cinfo!=null?"COM_CONFIG":"USER")
1729                });
1730
1731                //if(slotIdx == 7)
1732                //    alert("mounted DISKII "+peripheral_obj.mount.hash)
1733
1734                /*
1735                if (peripheral_obj.id && peripheral_obj.id.PCODE == "MS16K")  // temporary patch!!!  making sure oEMU.component.IO.RamCard (old) remains in sync with peripheral_obj, so that mem monitoring uses the same object between apple ram updates in EMU_apple2hw.js and ramcard ram updates in EMU_CARD_ramcard.js
1736                {
1737                    this.ramcard = peripheral_obj;
1738                    oEMU.component.IO.RamCard = peripheral_obj;
1739                }
1740                */
1741
1742                if(peripheral_info.slotLock==true) { slot_info.lock = true; } // inform the slot to never move this peripheral
1743
1744                if(peripheral_obj.action)
1745                {
1746                    const _act = peripheral_obj.action;     // ACTION_MAP
1747
1748                    const _bHostROM  = !(_act.HostROM === undefined);
1749                    const _bSlotIO   = !(_act.SlotIO  === undefined);
1750                    const _bSlotROM  = !(_act.SlotROM === undefined);
1751                    const _bHostIO   = !(_act.HostIO  === undefined);
1752
1753                    // PROVIDE THE BASE ADDRESS FOR EACH MEMORY SPACE
1754                    if(_bHostROM && ranges.HostROM) _act.HostROM.base = ranges.HostROM.from;
1755                    if(_bSlotROM && ranges.SlotROM) _act.SlotROM.base = ranges.SlotROM.from;
1756                    if(_bSlotIO  && ranges.SlotIO)  _act.SlotIO.base  = ranges.SlotIO.from;
1757                    if(_bHostIO  && ranges.HostIO)  _act.HostIO.base  = ranges.HostIO.from;
1758
1759                    // Add non-functional metadata to callbacks so diagnostics can show
1760                    // the actual mounted owner behind each ACTION_MAP span.
1761                    tagActionCallback(_act.HostROM && _act.HostROM.RD, "HostROM", "RD");
1762                    tagActionCallback(_act.HostROM && _act.HostROM.WR, "HostROM", "WR");
1763                    tagActionCallback(_act.SlotROM && _act.SlotROM.RD, "SlotROM", "RD");
1764                    tagActionCallback(_act.SlotROM && _act.SlotROM.WR, "SlotROM", "WR");
1765                    tagActionCallback(_act.SlotIO  && _act.SlotIO.RD,  "SlotIO",  "RD");
1766                    tagActionCallback(_act.SlotIO  && _act.SlotIO.WR,  "SlotIO",  "WR");
1767                    tagActionCallback(_act.HostIO  && _act.HostIO.RD,  "HostIO",  "RD");
1768                    tagActionCallback(_act.HostIO  && _act.HostIO.WR,  "HostIO",  "WR");
1769                    /*
1770                     * HostROM.WR is deliberately not installed here: HostROM is a
1771                     * shared C8 window and write dispatch follows ACTION_MAP.Hslot
1772                     * dynamically in Apple2IO.write().
1773                     */
1774                    if(_bHostROM && _act.HostROM.RD && ranges.HostROM) { for(var i=ranges.HostROM.from;i<=ranges.HostROM.to;i++) CIO.ACTION_MAP.RD[i] = _act.HostROM.RD.callback; }
1775                    if(_bSlotROM && _act.SlotROM.RD && ranges.SlotROM) { for(var i=ranges.SlotROM.from;i<=ranges.SlotROM.to;i++) CIO.ACTION_MAP.RD[i] = _act.SlotROM.RD.callback; }
1776                    if(_bSlotROM && _act.SlotROM.WR && ranges.SlotROM) { for(var i=ranges.SlotROM.from;i<=ranges.SlotROM.to;i++) CIO.ACTION_MAP.WR[i] = _act.SlotROM.WR.callback; }
1777                    if(_bSlotIO  && _act.SlotIO.RD  && ranges.SlotIO)  { for(var i=ranges.SlotIO.from; i<=ranges.SlotIO.to;i++)  CIO.ACTION_MAP.RD[i] = _act.SlotIO.RD.callback; }
1778                    if(_bSlotIO  && _act.SlotIO.WR  && ranges.SlotIO)  { for(var i=ranges.SlotIO.from; i<=ranges.SlotIO.to;i++)  CIO.ACTION_MAP.WR[i] = _act.SlotIO.WR.callback; }
1779                    if(_bHostIO && _act.HostIO.RD   && ranges.HostIO)  { for(var i=ranges.HostIO.from;i<=ranges.HostIO.to;i++)   CIO.ACTION_MAP.RD[i] = _act.HostIO.RD.callback; }
1780                    if(_bHostIO && _act.HostIO.WR   && ranges.HostIO)  { for(var i=ranges.HostIO.from;i<=ranges.HostIO.to;i++)   CIO.ACTION_MAP.WR[i] = _act.HostIO.WR.callback; }
1781                }
1782
1783                function tagActionCallback(action,rangeName,op)
1784                {
1785                    if(!action || typeof action.callback !== "function") return;
1786
1787                    action.callback._ioReport =
1788                    {
1789                         "PCODE": peripheralPCODE(peripheral_obj)
1790                        ,"slotTitle": slotN2name(slotIdx)
1791                        ,"range": rangeName
1792                        ,"op": op
1793                        ,"hash": peripheral_obj.mount.hash
1794                    };
1795                }
1796                if(remounting) refillEmptyIOActions();
1797            }
1798        }
1799
1800        return {"sInfo":slot_info,"pObj":peripheral_obj};
1801    }
1802
1803
1804    /*
1805     * Dynamic CPU memory mappings owned by peripherals.
1806     *
1807     * A peripheral registers a rule function. The rule receives a
1808     * JSON-compatible state object and returns the complete mapping
1809     * selected by that state.
1810     *
1811     * handler:
1812     *   "@default"  -> restore the Apple II default callback
1813     *   "methodName" -> use owner[methodName]
1814     */
1815    this.MEMORY_MAP =
1816    {
1817        rules: {},
1818        evidence: {},
1819
1820        addRule: function(id,ruleFunction)
1821        {
1822            if(!id || typeof ruleFunction != "function")
1823                return false;
1824
1825            this.rules[id] = ruleFunction;
1826            return true;
1827        },
1828
1829        runRule: function(id,state)
1830        {
1831            var ruleFunction = this.rules[id];
1832
1833            if(typeof ruleFunction != "function")
1834                return false;
1835
1836            return this.mount(ruleFunction(state || {}));
1837        },
1838
1839        mount: function(rule)
1840        {
1841            var hw = apple2plus.hwObj();
1842
1843            if(!rule || !rule.owner || !Array.isArray(rule.mappings))
1844                return false;
1845
1846            if(!hw || !hw.RD || !hw.WR || !hw.default_map)
1847                return false;
1848
1849            var owner = rule.owner;
1850            var hash = owner.mount && owner.mount.hash;
1851
1852            if(hash === undefined)
1853                return false;
1854
1855            var state = cloneMappingState(rule.state);
1856            var evidence = [];
1857
1858            for(var i=0;i<rule.mappings.length;i++)
1859            {
1860                var mapping = rule.mappings[i] || {};
1861                var op = String(mapping.op || "").toUpperCase();
1862                var range = parseCPUMappingRange(mapping.range);
1863
1864                if(op != "RD" && op != "WR")
1865                    continue;
1866
1867                if(!range)
1868                    continue;
1869
1870                /*
1871                 * Apple2Hw currently dispatches CPU memory through one
1872                 * callback per 4K line.
1873                 */
1874                if(
1875                    (range.from & 0x0FFF) != 0 ||
1876                    (range.to   & 0x0FFF) != 0x0FFF
1877                )
1878                {
1879                    console.warn(
1880                        "MEMORY_MAP: range must be aligned to 4K lines: "
1881                        + mapping.range
1882                    );
1883
1884                    continue;
1885                }
1886
1887                var callback = null;
1888
1889                if(mapping.handler != "@default")
1890                {
1891                    if(typeof mapping.callback == "function")
1892                    {
1893                        callback = mapping.callback;
1894                    }
1895                    else if(
1896                        mapping.handler &&
1897                        typeof owner[mapping.handler] == "function"
1898                    )
1899                    {
1900                        callback = owner[mapping.handler].bind(owner);
1901                    }
1902
1903                    if(typeof callback != "function")
1904                    {
1905                        console.warn(
1906                            "MEMORY_MAP: callback not found: "
1907                            + mapping.handler
1908                        );
1909
1910                        continue;
1911                    }
1912                }
1913
1914                for(
1915                    var address=range.from;
1916                    address<=range.to;
1917                    address+=0x1000
1918                )
1919                {
1920                    var line = hw.lineDecode(address);
1921
1922                    hw[op][line] =
1923                        mapping.handler == "@default"
1924                            ? hw.default_map[op][line]
1925                            : callback;
1926                }
1927
1928                /*
1929                 * Store only serialisable evidence. Function references
1930                 * remain in the actual hardware callback tables.
1931                 */
1932                evidence.push({
1933                     "id":        mapping.id || op+"_"+range.from
1934                    ,"space":     mapping.space || "MEMORY MAPPING"
1935                    ,"op":        op
1936                    ,"from":      range.from
1937                    ,"to":        range.to
1938                    ,"handler":   mapping.handler || ""
1939                    ,"target":    mapping.target || ""
1940                    ,"enabled":   mapping.enabled === undefined
1941                                    ? true
1942                                    : !!mapping.enabled
1943                    ,"condition": mapping.condition || ""
1944                    ,"source":    rule.source || ""
1945                    ,"state":     state
1946                });
1947            }
1948
1949            this.evidence[hash] = evidence;
1950
1951            /*
1952             * Also place the evidence directly in slotConfig[n], beneath
1953             * peripheral.mount, so the slot popup has one source.
1954             */
1955            if(!owner.mount)
1956                owner.mount = {};
1957
1958            owner.mount.mappings = evidence;
1959
1960            return evidence.length > 0;
1961        },
1962
1963        getEvidence: function(owner)
1964        {
1965            var hash = owner && owner.mount && owner.mount.hash;
1966
1967            return hash === undefined
1968                ? []
1969                : this.evidence[hash] || [];
1970        },
1971
1972        unmount: function(owner)
1973        {
1974            var hw = typeof(apple2plus)=="object" && apple2plus
1975                ? apple2plus.hwObj()
1976                : null;
1977
1978            if(!owner || !owner.mount || !hw || !hw.RD || !hw.WR || !hw.default_map)
1979                return false;
1980
1981            var hash = owner.mount.hash;
1982            var mappings = Array.isArray(owner.mount.mappings)
1983                ? owner.mount.mappings
1984                : (this.evidence[hash] || []);
1985
1986            for(var i=0;i<mappings.length;i++)
1987            {
1988                var mapping = mappings[i] || {};
1989                var op = String(mapping.op || "").toUpperCase();
1990                var from = Number(mapping.from);
1991                var to = Number(mapping.to);
1992
1993                if((op!="RD" && op!="WR") || !Number.isFinite(from) || !Number.isFinite(to))
1994                    continue;
1995
1996                for(var address=from;address<=to;address+=0x1000)
1997                {
1998                    var line = hw.lineDecode(address);
1999                    hw[op][line] = hw.default_map[op][line];
2000                }
2001            }
2002
2003            delete this.evidence[hash];
2004            delete this.rules[peripheralPCODE(owner)+":"+hash];
2005            owner.mount.mappings = [];
2006            return true;
2007        }
2008    };
2009
2010    function parseCPUMappingRange(spec)
2011    {
2012        var range;
2013
2014        if(typeof spec == "string")
2015        {
2016            range = oCOM.parseRngExpr(spec);
2017        }
2018        else if(spec && typeof spec == "object")
2019        {
2020            range = {
2021                 "from": Number(spec.from)
2022                ,"to":   spec.to === undefined
2023                            ? Number(spec.from)
2024                            : Number(spec.to)
2025            };
2026        }
2027
2028        if(
2029            !range ||
2030            !Number.isFinite(range.from) ||
2031            !Number.isFinite(range.to)
2032        )
2033            return null;
2034
2035        return range;
2036    }
2037
2038    function cloneMappingState(state)
2039    {
2040        try
2041        {
2042            return JSON.parse(JSON.stringify(state || {}));
2043        }
2044        catch(e)
2045        {
2046            return {};
2047        }
2048    }
2049
2050    // SLOT MAPPING
2051    /*
2052     * Discover peripheral types from the existing IO containers.
2053     *
2054     * _CFG_PSLOT defines which PCODEs are eligible. Merely being present in
2055     * oEMU.component.IO is not sufficient: the container must expose
2056     * id.PCODE, and that PCODE must occur in _CFG_PSLOT.
2057     *
2058     * The constructor is retained directly. No parallel registry and no
2059     * globalThis constructor lookup are required.
2060     */
2061    this.scanPeripheralContainers = function()
2062    {
2063        var discovered = {};
2064
2065        if(typeof(_CFG_PSLOT)=="undefined" ||
2066           !oEMU.component ||
2067           !oEMU.component.IO)
2068            return discovered;
2069
2070        for(var coID in oEMU.component.IO)
2071        {
2072            var container = oEMU.component.IO[coID];
2073            var PCODE = container?.id?.PCODE;
2074
2075            if(!PCODE || !_CFG_PSLOT[PCODE]) continue;
2076            if(typeof(container.constructor)!="function") continue;
2077            if(container.constructor===Object) continue;
2078
2079            var info = Object.assign({},container.id,{
2080                 "PCODE":PCODE
2081                ,"coID":coID
2082                ,"ctor":container.constructor
2083            });
2084
2085            if(info.description===undefined)
2086                info.description = _CFG_PSLOT[PCODE].NAME;
2087
2088            discovered[PCODE] = info;
2089        }
2090
2091        return discovered;
2092    }
2093    function extract_slotrange_mask(str)
2094    {
2095        var mask = new Uint16Array(2);
2096        mask[0] = 0; mask[1] = 0;
2097
2098        var arr = str.split(",");
2099        for(var i=0;i<arr.length;i++)
2100        {
2101            var n = Number( arr[i].replace(RegExp("\\*","g"),"") );
2102            if(isNaN(n)==false)         mask[0] |= 1<<n
2103            if(arr[i].indexOf("*")>=0)  mask[1] |= 1<<n
2104        } 
2105        return mask; // [0]:compatible slotrange for peripheral  [1]:pre-installed slots with peripheral
2106    }
2107
2108    function extract_slotrange(str)
2109    {
2110        var  slotPut = [], slotFit = [];
2111        var arr = str.split(",");
2112        for(var i=0;i<arr.length;i++)
2113        {
2114            var n = Number( arr[i].replace(RegExp("\\*","g"),"") );
2115            if(isNaN(n)==false)
2116            {
2117                slotFit.push( n );
2118            }
2119            if(arr[i].indexOf("*")>=0)  
2120            { 
2121                slotPut.push( n );
2122            }
2123        } 
2124        return {"slotPut":slotPut, "slotFit":slotFit}
2125    }    
2126
2127    function extract_slotRef(str,PCODE,ref)
2128    {
2129        if(ref.slotFit===undefined) ref.slotFit = [];
2130        if(ref.slotMap===undefined) ref.slotMap = [];
2131        var arr = str.split(",");
2132        for(var i=0;i<arr.length;i++)
2133        {
2134            var n = slotID2n( arr[i].replace(RegExp("\\*","g"),"") );
2135
2136            if(ref.slotFit[n]===undefined) { ref.slotFit[n] = [] };
2137            ref.slotFit[n].push(PCODE);
2138
2139            if(arr[i].indexOf("*")>=0)  
2140            {       
2141                if(ref.slotMap[n]===undefined) { ref.slotMap[n] = [] };
2142                ref.slotMap[n].push(PCODE);
2143            }
2144        } 
2145        return {"slotMap":ref.slotMap, "slotFit":ref.slotFit}
2146    }     
2147
2148    function slotID2n(slotID)
2149    {
2150        // previously slotID == "B"
2151        return slotID == "H" ? 0 : Number(slotID) + 1;
2152    }
2153
2154    this.slot2ID = function(slotN)
2155    {
2156        // previously slotID == "B"
2157        return slotN == 0 ? "H" : Number(slotN) - 1;
2158    }
2159
2160    function slotN2name(slotN)  // name = display name of a slot
2161    {
2162        return slotN == 0 ? "board" : ("PR#"+(slotN-1));
2163    }
2164
2165    function slotName2n(slotName)
2166    {
2167        return slotName=="board" ? 0 : Number(slotName.slice(-1))+1
2168    }
2169
2170    function peripheralPCODE(p)
2171    {
2172        return p ? (p.id && p.id.PCODE ? p.id.PCODE : (p.PCODE || "")) : "";
2173    }
2174
2175    function peripheralIcon(p)
2176    {
2177        return p ? (p.id && p.id.icon ? p.id.icon : (p.icon || "fa fa-cube")) : "fa fa-cube";
2178    }
2179
2180    function peripheralCoID(p)
2181    {
2182        return p ? (p.id && p.id.coID ? p.id.coID : (p.coID || (p.constructor ? p.constructor.name : ""))) : "";
2183    }
2184
2185    function peripheralDescription(p)
2186    {
2187        return p ? (p.id && p.id.description ? p.id.description : (p.description || "")) : "";
2188    }
2189
2190    function peripheralRange(p,rangeName)
2191    {
2192        if(!p) return undefined;
2193        if(p.mount && p.mount.ranges && p.mount.ranges[rangeName]) return p.mount.ranges[rangeName];
2194        return p[rangeName]; // legacy fallback
2195    }
2196
2197    this.HASH2obj = function(HASH)
2198    {
2199        for(var o in this.slots)
2200        {
2201            if(this.slots[o].peripheral?.mount.hash == HASH)
2202                return this.slots[o].peripheral;         // we can have multiple matches, since we do not specify slotN
2203        }
2204        return null;
2205    }
2206
2207    this.SLOT2obj = function(slotN)
2208    {
2209        slotN = Number(slotN);
2210
2211        if(
2212            !Number.isInteger(slotN) ||
2213            !this.slots[slotN] ||
2214            !this.slots[slotN].peripheral
2215        )
2216            return null;
2217
2218        return this.slots[slotN].peripheral;
2219    }
2220
2221    this.DeviceName2obj = function(str)
2222    {
2223
2224    }
2225
2226    this.deviceID2obj = function(str,slotN)
2227    {
2228        if(typeof str !== "string") return null;
2229
2230        var DCODE = str.trim().toUpperCase();
2231        if(!DCODE) return null;
2232
2233        var slotNumbers = [];
2234        var requestedSlotN = Number(slotN);
2235
2236        if(slotN!==undefined && slotN!==null && Number.isInteger(requestedSlotN))
2237            slotNumbers.push(requestedSlotN);
2238        else
2239        {
2240            for(var mountedSlotN in this.slots)
2241                if(this.slots[mountedSlotN] && this.slots[mountedSlotN].peripheral)
2242                    slotNumbers.push(Number(mountedSlotN));
2243        }
2244
2245        for(var s=0;s<slotNumbers.length;s++)
2246        {
2247            var resolvedSlotN = slotNumbers[s];
2248            var owner = this.SLOT2obj(resolvedSlotN);
2249            var devices = owner && Array.isArray(owner.devices) ? owner.devices : [];
2250
2251            for(var d=0;d<devices.length;d++)
2252            {
2253                var device = devices[d];
2254                var id = device && device.id ? device.id : {};
2255                var candidate = String(id.DCODE || "").toUpperCase();
2256
2257                if(candidate!=DCODE) continue;
2258
2259                var deviceN = Number(id.deviceN);
2260                if(!Number.isInteger(deviceN)) deviceN = d;
2261
2262                return {
2263                     "slotN":resolvedSlotN
2264                    ,"slotID":this.slot2ID(resolvedSlotN)
2265                    ,"periID":peripheralPCODE(owner)
2266                    ,"DCODE":candidate
2267                    ,"deviceID":candidate
2268                    ,"deviceN":deviceN
2269                };
2270            }
2271        }
2272
2273        return null;
2274    };
2275
2276    this.obj2deviceID = function(obj)
2277    {
2278        if(obj == null) return null;
2279
2280        if(typeof obj === "string")
2281            return obj.trim().toUpperCase() || null;
2282
2283        var DCODE =
2284               obj.id?.DCODE
2285            || obj.DCODE
2286            || obj.deviceID
2287            || obj.device?.id?.DCODE;
2288
2289        if(typeof DCODE === "string")
2290            return DCODE.trim().toUpperCase() || null;
2291
2292        return null;
2293    };
2294
2295    this.deviceN2ID = function(n,PCODE,slotN)
2296    {
2297        var owner = Number.isInteger(Number(slotN))
2298            ? this.SLOT2obj(Number(slotN))
2299            : this.PCODE2obj(PCODE)[0];
2300        if(PCODE && peripheralPCODE(owner)!==PCODE) return null;
2301
2302        var devices = owner && Array.isArray(owner.devices) ? owner.devices : [];
2303        var deviceN = Number(n);
2304
2305        for(var i=0;i<devices.length;i++)
2306        {
2307            var ordinal = Number(devices[i]?.id?.deviceN);
2308            if(!Number.isInteger(ordinal)) ordinal = i;
2309            if(ordinal===deviceN) return this.obj2deviceID(devices[i]);
2310        }
2311
2312        return null;
2313    }
2314
2315    this.deviceID2N = function(str,PCODE,slotN)
2316    {
2317        var ref = this.deviceID2obj(str,slotN);
2318        return ref && (!PCODE || ref.periID===PCODE) ? ref.deviceN : undefined;
2319    }
2320
2321    this.config_slotAvail = function(cfg)        // HOW MANY SLOTS CAN WE FILL?
2322    {
2323        var model = typeof(EMU_system_get)=="function" ? EMU_system_get() : "A2P";
2324        if(cfg===undefined) { var cfg = "[0-7] [0-7]"; console.warn("CONFIG file is not available (missing _CFG_SYSCODE)"); }
2325        var str = cfg[model]?.Slotslogphy;
2326        var a = parseSlotAvail(str);
2327
2328        // dataset completions
2329        for(var i=0;i<a.logSlots.length;i++)  a.logSlots[i]  = "PR#" + a.logSlots[i];
2330        for(var i in a.lockSlots)             a.lockSlots[i] = "PR#" + a.lockSlots[i];
2331        
2332        a.logSlots.unshift("board"); a.lockSlots["board"] = true;
2333        return a;
2334    }
2335
2336    function parseSlotAvail(s)
2337    {
2338        if(s===undefined) return {};
2339        let a=[...s.matchAll(/\[(\d+)-(\d+)\]/g)].map(m=>
2340        Array.from({length:m[2]-m[1]+1},(_,i)=>+m[1]+i)
2341        );
2342        return {
2343        logSlots:a[0],
2344        phySlots:a[1],
2345        lockSlots:Object.fromEntries(a[0].filter(n=>!a[1].includes(n)).map(n=>[n,true])),
2346        logSlots_n:a[0].length,
2347        phySlots_n:a[1].length
2348        };
2349    }
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362    // SLOT MAPPING
2363    //var SLOT_MAP  = new Uint16Array(8<<3);   // SLOT ADDRESS MAPPING
2364    //var SLOT_NAME = new Array(8);           // 8 SLOTS, 6 CHARACTERS PER SLOT NAME
2365    var SLOT_IDX  = new Array(8);
2366    var SLOT_REG  = [];
2367
2368    var model = typeof(EMU_system_get)=="function" ? EMU_system_get() : "A2P";
2369
2370    var slot_count = 0;
2371    if(typeof(_CFG_SYSCODE)!="undefined")
2372    {
2373        var slotAvail = this.config_slotAvail(_CFG_SYSCODE);
2374        // example: slotAvail={"logSlots":["board","PR#0","PR#1","PR#2","PR#3","PR#4","PR#5","PR#6","PR#7"],"phySlots":[0,1,2,3,4,5,6,7],"lockSlots":{"board":true},"logSlots_n":8,"phySlots_n":8}
2375        var slot_count = slotAvail.logSlots_n;
2376    }
2377
2378    var slotR = {};  // TODO: remove as this is now in CONFIG     
2379    
2380    if(typeof(_CFG_PSLOT)!="undefined") // DO WE HAVE A CONFIGURATION FILE FOR OUR PERIPHERALS?
2381    {
2382        // LOAD ALL PERIPHERALS FROM CONFIGURATION
2383        for(var PCODE in _CFG_PSLOT)
2384        {
2385            var srange = _CFG_PSLOT[PCODE].SLOTrange.split(",");
2386            //var slotrange_mask = extract_slotrange_mask(_CFG_PSLOT[PCODE].SLOTrange);  // [0]:compatible slotrange for peripheral  [1]:pre-installed slots with peripheral
2387            slotR = extract_slotRef(_CFG_PSLOT[PCODE].SLOTrange,PCODE,slotR);
2388        }
2389
2390        //slotR = {"slotMap":[["A2BO"],["MS16K"],null,null,["VIDEX"],["MOCK"],null,["DISKII"]]
2391        //        ,"slotFit":[["A2BO"],["MS16K"],["DISKII","MOCK"],["DISKII","MOCK"],["DISKII","VIDEX","MOCK"],["DISKII","MOCK"],["DISKII","MOCK"],["DISKII","MOCK"],["DISKII","MOCK"]]}
2392
2393    }
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403//      ______   __          _                               ___  _          
2404//    .' ____ \ [  |        / |_                           .' ..](_)         
2405//    | (___ \_| | |  .--. `| |-'  .---.   .--.   _ .--.  _| |_  __   .--./) 
2406//     _.____`.  | |/ .'`\ \| |   / /'`\]/ .'`\ \[ `.-. |'-| |-'[  | / /'`\; 
2407//    | \____) | | || \__. || |,  | \__. | \__. | | | | |  | |   | | \ \._// 
2408//     \______.'[___]'.__.' \__/  '.___.' '.__.' [___||__][___] [___].',__`  
2409//                                                                  ( ( __)) 
2410
2411    this.onSlotAdd = function(ctx, ev) 
2412    {
2413        var anchor = ev.target.closest(".slot-anchor.empty");
2414        if (!anchor || !ctx.host.contains(anchor)) return;
2415
2416        var slotTitle = anchor.dataset.slotId;
2417        this.slotPicker_popup(ctx, slotTitle);
2418    };
2419
2420    this.onSlotMove = function(ctx, ev, fromSlotId, toSlotId)
2421    {
2422      var parent = ev.currentTarget.parentElement.parentElement.parentElement;
2423      var periID = parent.children[0].id;
2424
2425      //console.log(JSON.stringify(ctx.slots));
2426      //alert("Move " + ctx.hostId + " peripheral "+periID+" slot " + fromSlotId + "->" + toSlotId);
2427      //alert("onSlotMove checkpoint: this.slots["+toSlotId+"] = "+JSON.stringify(this.slots[ slotName2n(toSlotId) ]));
2428    };
2429
2430    this.slotsRender = function(elid, cfg)
2431    {
2432      if (!elid) return;
2433
2434      if (!this.slot_ctx[elid])
2435      {
2436        this.slot_ctx[elid] = {
2437          hostId: elid,
2438          host: null,
2439          slots: null,
2440          clickBound: false,
2441          pointerDrag: null
2442        };
2443      }
2444
2445      var ctx = this.slot_ctx[elid];
2446      if (cfg !== undefined) ctx.slots = cfg;
2447      if (!ctx.slots) ctx.slots = this.slots;
2448
2449      ctx.host = document.getElementById(elid);
2450      if (!ctx.host) return;
2451
2452      function getSlotById(Cfg, slotTitle) { return Cfg.find(function(slot) { return slot.slotTitle === slotTitle; }) }
2453
2454      function renderGhost(slot)
2455      {
2456        var iconClass = slot && slot.peripheral ? peripheralIcon(slot.peripheral) : "fa fa-circle";
2457
2458        return `
2459          <button class="appbut ghost-cog" type="button" tabindex="-1" aria-hidden="true">
2460            <i class="${iconClass}"></i>
2461          </button>
2462          <span class="ghost-handle" aria-hidden="true">
2463            <i class="fa fa-ellipsis-h"></i>
2464            <i class="fa fa-ellipsis-h"></i>
2465          </span>
2466        `;
2467      }
2468
2469      function renderAnchor(slot)
2470      {
2471        var anchorId = "d_slot_" + ctx.hostId + "_" + slot.slotTitle.replace(/[^a-zA-Z0-9_-]/g, "_");
2472
2473        if (slot.peripheral)
2474        {
2475          return `
2476            <div class="slot-anchor occupied ${slot.lock ? "locked" : "movable"}"
2477                  id="${anchorId}"
2478                  data-slot-id="${slot.slotTitle}"
2479                  data-host-id="${ctx.hostId}">
2480              <div class="peripheral-card">
2481                ${slot.lock ? "" : `
2482                  <span class="drag-handle" title="Move ${peripheralPCODE(slot.peripheral)}">
2483                    <i class="fa fa-ellipsis-h dots dots1"></i>
2484                    <i class="fa fa-ellipsis-h dots"></i>
2485                  </span>
2486                `}
2487              </div>
2488            </div>
2489          `;
2490        }
2491
2492        return `
2493          <div class="slot-anchor empty"
2494                id="${anchorId}"
2495                data-slot-id="${slot.slotTitle}"
2496                data-host-id="${ctx.hostId}">
2497            <button class="slot-add" type="button" title="Add peripheral to ${slot.slotTitle}">
2498              <i class="fa fa-plus dots dots1"></i>
2499            </button>
2500          </div>
2501        `;
2502      }
2503
2504      function renderSlot(slot)
2505      {
2506        return `
2507          <div class="appbox slotbox slot"
2508                data-slot-id="${slot.slotTitle}"
2509                data-host-id="${ctx.hostId}">
2510            <div class="slot-label">${slot.slotTitle}</div>
2511            <div class="sloticons">
2512              ${slot.peripheral
2513                ? `<button id="${peripheralPCODE(slot.peripheral)}" class="appbut cogbtn" type="button" title="configure ${peripheralPCODE(slot.peripheral)} / ${peripheralCoID(slot.peripheral)}()"><i class="${peripheralIcon(slot.peripheral)}"></i></button>`
2514                : ""}
2515              ${renderAnchor(slot)}
2516            </div>
2517          </div>
2518        `;
2519      }
2520
2521      function buildDragGhost(slot)
2522      {
2523        var ghost = document.createElement("div");
2524        ghost.className = "drag-ghost";
2525        ghost.innerHTML = renderGhost(slot);
2526        document.body.appendChild(ghost);
2527        return ghost;
2528      }
2529
2530      function positionDragGhost(x, y)
2531      {
2532        if (!ctx.pointerDrag || !ctx.pointerDrag.ghostEl) return;
2533        ctx.pointerDrag.ghostEl.style.left = x + "px";
2534        ctx.pointerDrag.ghostEl.style.top  = y + "px";
2535      }
2536
2537      function updatePointerHotTarget(x, y)
2538      {
2539        if (!ctx.pointerDrag) return;
2540        var el = document.elementFromPoint(x, y);
2541        var nextHot = el ? el.closest('[data-host-id="' + ctx.hostId + '"].slot-anchor.empty') : null;
2542        if (ctx.pointerDrag.hotTarget === nextHot) return;
2543        if (ctx.pointerDrag.hotTarget) ctx.pointerDrag.hotTarget.classList.remove("drop-ok");
2544        ctx.pointerDrag.hotTarget = nextHot;
2545        if (ctx.pointerDrag.hotTarget) ctx.pointerDrag.hotTarget.classList.add("drop-ok");     
2546      }
2547
2548      function cleanupPointerDrag()
2549      {
2550        if (!ctx.pointerDrag) return;
2551        var handleEl = ctx.pointerDrag.handleEl;
2552        var pointerId = ctx.pointerDrag.pointerId;
2553        if (ctx.pointerDrag.hotTarget) ctx.pointerDrag.hotTarget.classList.remove("drop-ok");
2554        if (ctx.pointerDrag.ghostEl) ctx.pointerDrag.ghostEl.remove();
2555        if (handleEl)
2556        {
2557          try { if (handleEl.hasPointerCapture(pointerId)) handleEl.releasePointerCapture(pointerId) }
2558          catch (e) {}
2559
2560          handleEl.removeEventListener("pointermove", onPointerDragMove);
2561          handleEl.removeEventListener("pointerup", onPointerDragEnd);
2562          handleEl.removeEventListener("pointercancel", onPointerDragCancel);
2563        }
2564
2565        document.body.classList.remove("dragging");
2566        ctx.host.querySelectorAll(".slot.drag-source").forEach(function(el)
2567          {
2568            el.classList.remove("drag-source");
2569          });
2570
2571          ctx.pointerDrag = null;
2572      }
2573
2574      function onPointerDragStart(emui, host, ev)
2575      {
2576        if (ev.button != null && ev.button !== 0) return;
2577
2578        ev.preventDefault();
2579        ev.stopPropagation();
2580
2581        var slotEl = ev.currentTarget.closest(".slot");
2582        if (!slotEl) return;
2583
2584        var fromSlotId = slotEl.dataset.slotId;
2585        var fromSlot = getSlotById(ctx.slots, fromSlotId);
2586        if (!fromSlot || !fromSlot.peripheral) return;
2587
2588        ctx.pointerDrag = {
2589          emui: emui,
2590          host: host,
2591          pointerId: ev.pointerId,
2592          fromSlotId: fromSlotId,
2593          handleEl: ev.currentTarget,
2594          hotTarget: null,
2595          ghostEl: buildDragGhost(fromSlot)
2596        };
2597
2598        document.body.classList.add("dragging");
2599        slotEl.classList.add("drag-source");
2600
2601        ev.currentTarget.setPointerCapture(ev.pointerId);
2602        ev.currentTarget.addEventListener("pointermove", onPointerDragMove);
2603        ev.currentTarget.addEventListener("pointerup", onPointerDragEnd);
2604        ev.currentTarget.addEventListener("pointercancel", onPointerDragCancel);
2605
2606        positionDragGhost(ev.clientX, ev.clientY);
2607        updatePointerHotTarget(ev.clientX, ev.clientY);
2608      }
2609
2610      function onPointerDragMove(ev)
2611      {
2612        if (!ctx.pointerDrag || ev.pointerId !== ctx.pointerDrag.pointerId) return;
2613
2614        ev.preventDefault();
2615        positionDragGhost(ev.clientX, ev.clientY);
2616        updatePointerHotTarget(ev.clientX, ev.clientY);
2617      }
2618
2619      function onPointerDragEnd(ev)
2620      {
2621        if (!ctx.pointerDrag || ev.pointerId !== ctx.pointerDrag.pointerId) return;
2622        ev.preventDefault();
2623        var fromSlotId = ctx.pointerDrag.fromSlotId;
2624        var toSlotId = ctx.pointerDrag.hotTarget ? ctx.pointerDrag.hotTarget.dataset.slotId : null;
2625        var emui = ctx.pointerDrag.emui;
2626        cleanupPointerDrag();
2627        if (toSlotId && slotMove(ctx.slots, fromSlotId, toSlotId))
2628        {
2629          emui.slotsRender(ctx.hostId);
2630          console.log("Move peripheral from->to", ctx.hostId, fromSlotId, toSlotId);
2631          emui.onSlotMove(ctx, ev, fromSlotId, toSlotId);
2632        }
2633
2634        function slotMove(slots, fromSlotId, toSlotId)
2635        {
2636            if (!slots ||
2637                !fromSlotId ||
2638                !toSlotId ||
2639                fromSlotId === toSlotId)
2640                return false;
2641
2642            var from = getSlotById(slots, fromSlotId);
2643            var to   = getSlotById(slots, toSlotId);
2644
2645            if (!from ||
2646                !to ||
2647                from.lock ||
2648                !from.peripheral ||
2649                to.peripheral)
2650                return false;
2651
2652            var peripheral = from.peripheral;
2653            var pcode = peripheralPCODE(peripheral);
2654            var discovered = emui.scanPeripheralContainers();
2655            var pinfo = discovered[pcode] || null;
2656            var cinfo = typeof(_CFG_PSLOT)!="undefined" ? _CFG_PSLOT[pcode] : null;
2657            var toSlotN = slotName2n(toSlotId);
2658            var mounted = emui.mount(
2659                 cinfo
2660                ,pinfo
2661                ,toSlotN
2662                ,slotR.slotFit ? slotR.slotFit[toSlotN] : []
2663                ,peripheral
2664
2665            );
2666            if(!mounted || !mounted.pObj) return false;
2667
2668            for(var key in mounted.sInfo) to[key] = mounted.sInfo[key];
2669            to.peripheral = mounted.pObj;
2670
2671            var model = typeof(EMU_system_get)=="function" ? EMU_system_get() : "A2P";
2672            emui.provisionPeripheral(mounted.pObj,model);
2673            refillEmptyIOActions();
2674
2675            delete from.peripheral;
2676            return true;
2677        }
2678      }
2679
2680      function onPointerDragCancel(ev) { if (ctx.pointerDrag && ev.pointerId == ctx.pointerDrag.pointerId) cleanupPointerDrag() }
2681      function onClick(emui, ctx, ev) { emui.onSlotClick(ctx, ev) }
2682      function onHostClick(emui, ctx, ev) 
2683      { 
2684        var addBtn = ev.target.closest(".slot-add");
2685        if (addBtn && ctx.host.contains(addBtn)) emui.onSlotAdd(ctx, ev); 
2686      }
2687
2688      function wireEvents(emui, ctx)
2689      {
2690        ctx.host.querySelectorAll(".drag-handle").forEach(function(el)
2691        {
2692          el.addEventListener("pointerdown", function(ev) { onPointerDragStart(emui, ctx, ev) });
2693        });
2694
2695        ctx.host.querySelectorAll(".appbut").forEach(function(el)
2696        {
2697          el.addEventListener("click", function(ev) { onClick(emui, ctx, ev) });
2698        });
2699
2700        if (!ctx.clickBound)
2701        {
2702          ctx.host.addEventListener("click", function(ev) { onHostClick(emui, ctx, ev) });
2703          ctx.clickBound = true;
2704        }
2705      }
2706      ctx.host.innerHTML = (ctx.slots || []).map(renderSlot).join("");
2707      
2708      wireEvents(this, ctx);
2709    };
2710
2711    this.onSlotClick = function(ctx, ev)
2712    {
2713      var parent  = ev.currentTarget.closest(".slot");
2714      var periID = ev.currentTarget.id;
2715      var slotID  = parent ? parent.dataset.slotId : "";
2716      var hostId  = ctx.hostId;
2717
2718      this.slotConfig_detail(slotID);
2719
2720
2721      //oCOM.POPUP.toggle("slotConfig_popup");
2722
2723      console.log(JSON.stringify(ctx.slots));
2724    };
2725
2726
2727    this.slotConfig = function(arg)
2728    {
2729        if(arg.active==false) document.getElementById(arg.id).innerHTML = "";
2730
2731        //var ss = "<div class=\"appbut\" onclick=\"oCOM.POPUP.toggle('"+wrapper_id+"');\" style=\"text-align:center;float:right;\">x</div>"
2732        var s = "document.getElementById('"+arg.id+"').innerHTML='"+arg.id+"';"
2733        if(document.getElementById(arg.id)!=null)
2734            document.getElementById(arg.id).innerHTML = "<button class=appbut onclick=\"apple2plus.hwObj().io.slotConfig_detail('"+arg.id+"')\" style=\"margin-left:0px\"><i class=\""+arg.icon+"\"></i></button>"
2735    }
2736
2737    this.slotConfig_refresh = function(slotN)
2738    {
2739        slotN=Number(slotN);
2740        var slot=this.slots[slotN] || {};
2741        var popup=document.getElementById("slotConfig_popup");
2742        if(!popup) return false;
2743
2744        var close="<div class=\"appbut\" onclick=\"oCOM.POPUP.toggle('slotConfig_popup');apple2plus.hwObj().io.deviceConfig_close();\" style=\"text-align:center;float:right;\">x</div>";
2745        var html=close+slotConfigDetail_html(slot);
2746        if(slotN==0)
2747        {
2748            html += "<div style='overflow-y:scroll;height:350px'>";
2749            var model=typeof(EMU_system_get)=="function" ? EMU_system_get() : "A2P";
2750            var board=this.SLOT2obj(0);
2751            html += "<div class='appbox' style='float:none;'>"+(board && typeof(board.boardIO_html)=="function" ? board.boardIO_html(model) : "")+"</div>";
2752            html += "</div>";
2753        }
2754        popup.innerHTML=html;
2755        return true;
2756    };
2757
2758    this.slotConfig_detail = function(slotName)
2759    {
2760        var n=slotName2n(slotName);
2761        this.slotConfig_refresh(n);
2762        this.deviceConfig_close();
2763        oCOM.POPUP.toggle("slotConfig_popup");
2764    }
2765
2766    this.slotConfig_download = function(slotN)
2767    {
2768        slotN = Number(slotN);
2769        var slot = this.slots[slotN];
2770        if(!slot) return false;
2771
2772        try
2773        {
2774            var pcode = peripheralPCODE(slot.peripheral) || "slot";
2775            var slotName = slotN2name(slotN).replace("#","");
2776            var fileName = (pcode+"_"+slotName).replace(/[^A-Za-z0-9_.-]/g,"_")+".json";
2777            var json = JSON.stringify(slot,null,2);
2778            oCOM.Download(fileName,new TextEncoder("utf-8").encode(json));
2779            return true;
2780        }
2781        catch(e)
2782        {
2783            console.error("Slot JSON download failed",e);
2784            return false;
2785        }
2786    }
2787
2788    this.slotConfig_eject = function(slotN)
2789    {
2790        slotN = Number(slotN);
2791        var slot = this.slots[slotN];
2792
2793        if(!Number.isInteger(slotN) || !slot || slot.lock || !slot.peripheral)
2794            return false;
2795
2796        var previous = document.getElementById("slotConfig_eject_confirm");
2797        if(previous) previous.remove();
2798
2799        var io = this;
2800        var peripheral = slot.peripheral;
2801        var dialog = document.createElement("dialog");
2802        dialog.id = "slotConfig_eject_confirm";
2803        dialog.className = "appbox";
2804        dialog.style.cssText = "max-width:360px;text-align:left;padding:12px";
2805
2806        var question = document.createElement("div");
2807        question.style.marginBottom = "12px";
2808        question.textContent = "Eject "
2809            + peripheralPCODE(peripheral)
2810            + " from "
2811            + slotN2name(slotN)
2812            + "?";
2813
2814        var note = document.createElement("div");
2815        note.style.cssText = "font-size:11px;margin-bottom:12px";
2816        note.textContent = "The peripheral and its active I/O mappings will be removed.";
2817
2818        var buttons = document.createElement("div");
2819        buttons.style.textAlign = "right";
2820
2821        var noButton = document.createElement("button");
2822        noButton.type = "button";
2823        noButton.className = "appbut";
2824        noButton.textContent = "No";
2825
2826        var yesButton = document.createElement("button");
2827        yesButton.type = "button";
2828        yesButton.className = "appbut";
2829        yesButton.textContent = "Yes";
2830
2831        function eraseDialog()
2832        {
2833            if(dialog.open && typeof(dialog.close)=="function")
2834                dialog.close();
2835            dialog.remove();
2836        }
2837
2838        noButton.onclick = eraseDialog;
2839        dialog.addEventListener("cancel",function(ev)
2840        {
2841            ev.preventDefault();
2842            eraseDialog();
2843        });
2844
2845        yesButton.onclick = function()
2846        {
2847            var removed = io.unmount(slotN);
2848            eraseDialog();
2849            if(!removed) return;
2850
2851            var detail = document.getElementById("slotConfig_popup");
2852            if(detail)
2853            {
2854                detail.innerHTML = "";
2855                oCOM.POPUP.off("slotConfig_popup");
2856            }
2857
2858            for(var hostId in io.slot_ctx)
2859                io.slotsRender(hostId);
2860
2861            if(typeof(io.refreshDeviceToolboxes)=="function")
2862                io.refreshDeviceToolboxes({"id":"devices"});
2863        };
2864
2865        buttons.appendChild(noButton);
2866        buttons.appendChild(yesButton);
2867        dialog.appendChild(question);
2868        dialog.appendChild(note);
2869        dialog.appendChild(buttons);
2870        document.body.appendChild(dialog);
2871
2872        if(typeof(dialog.showModal)=="function") dialog.showModal();
2873        else dialog.setAttribute("open","");
2874        return true;
2875    }
2876
2877    function slotConfigDetail_html(slot)
2878    {
2879        var peripheral = slot && slot.peripheral;
2880        if(!peripheral)
2881            return "<div class='appbox' style='margin-top:8px;padding:8px'>No peripheral mounted.</div>";
2882
2883        var rows = [];
2884        var rangeNames = ["HostIO","SlotIO","SlotROM","HostROM"];
2885
2886        // Static mappings declared by peripheral.action and peripheral.mount.ranges.
2887        for(var i=0;i<rangeNames.length;i++)
2888        {
2889            var rangeName = rangeNames[i];
2890            var range = peripheralRange(peripheral,rangeName);
2891            if(!range) continue;
2892
2893            var action = peripheral.action && peripheral.action[rangeName]
2894                ? peripheral.action[rangeName]
2895                : {};
2896            var operations = [];
2897
2898            if(action.RD && typeof(action.RD.callback)=="function")
2899                operations.push({"op":"RD","title":"RD -> "+callbackLabel(action.RD.callback)});
2900            if(action.WR && typeof(action.WR.callback)=="function")
2901                operations.push({"op":"WR","title":"WR -> "+callbackLabel(action.WR.callback)});
2902
2903            rows.push({
2904                 "space":rangeName
2905                ,"cpuFrom":range.from+ioBase()
2906                ,"cpuTo":range.to+ioBase()
2907                ,"offsetFrom":range.from
2908                ,"offsetTo":range.to
2909                ,"operations":operations
2910            });
2911        }
2912
2913        // Dynamic CPU mappings registered through MEMORY_MAP.
2914        var dynamicMappings = peripheral.mount && Array.isArray(peripheral.mount.mappings)
2915            ? peripheral.mount.mappings
2916            : [];
2917
2918        for(var j=0;j<dynamicMappings.length;j++)
2919        {
2920            var mapping = dynamicMappings[j];
2921            var space = mapping.space || "MEMORY MAPPING";
2922            var operation = {
2923                 "op":mapping.op
2924                ,"title":mappingTooltip(mapping)
2925                ,"enabled":mapping.enabled!==false
2926            };
2927            var row = rows.find(function(candidate)
2928            {
2929                return candidate.space===space &&
2930                    candidate.cpuFrom===mapping.from &&
2931                    candidate.cpuTo===mapping.to &&
2932                    candidate.offsetFrom===undefined &&
2933                    candidate.offsetTo===undefined;
2934             });
2935
2936            /* Keep RD/WR as separate evidence, but combine their display row. */
2937            if(row)
2938                row.operations.push(operation);
2939            else
2940                rows.push({
2941                     "space":space
2942                    ,"cpuFrom":mapping.from
2943                    ,"cpuTo":mapping.to
2944                    ,"operations":[operation]
2945                });
2946
2947        }
2948
2949        var body = "";
2950        for(var r=0;r<rows.length;r++) body += mappingRow_html(rows[r]);
2951        if(!body) body = "<tr><td colspan='4' style='padding:8px'>No mappings registered.</td></tr>";
2952 
2953        var slotN = Number(peripheral.mount.slotN);
2954        var ejectButton = "<button class=\"appbut\" type=\"button\""
2955            + (slot.lock
2956                ? " disabled title=\"This peripheral is locked\""
2957                : " title=\"Eject peripheral\""
2958                    + " onclick=\"event.stopPropagation();apple2plus.hwObj().io.slotConfig_eject("+slotN+")\"")
2959            + ">"
2960            + "<i class=\"fa fa-eject\"></i>"
2961            + "</button>";
2962        return "<div class='appbox' style='float:none;width:520px;max-width:80vw;overflow:auto;margin-top:8px;padding:8px'>"
2963            + "<div><b>"+oCOM.escapeHTML(peripheralPCODE(peripheral))+"</b>"
2964            + (peripheralDescription(peripheral) ? " &mdash; "+oCOM.escapeHTML(peripheralDescription(peripheral)) : "")
2965            + " &mdash; " + slotN2name(slot.peripheral.mount.slotN)
2966            + "&nbsp;<button class=\"appbut\" type=\"button\""
2967            + " title=\"Download slot JSON\""
2968            + " onclick=\"event.stopPropagation();apple2plus.hwObj().io.slotConfig_download("+slotN+")\">"
2969            + "<i class=\"fa fa-cloud-download-alt\"></i></button>"
2970            + "&nbsp;"+ejectButton
2971            + "</div>"
2972            
2973            + "<table style='width:100%;border-collapse:collapse;margin-top:8px;text-align:left'>"
2974            + "<thead><tr style='border-bottom:1px solid #888'>"
2975            + "<th style='padding:4px 6px'>Space</th>"
2976            + "<th style='padding:4px 6px'>CPU address</th>"
2977            + "<th style='padding:4px 6px'>I/O offset</th>"
2978            + "<th style='padding:4px 6px'>R/W</th>"
2979            + "</tr></thead>"
2980            + "<tbody>"+body+"</tbody>"
2981            + "</table>"
2982            + slotDeviceTable_html(peripheral)
2983            + "</div>"
2984    }
2985
2986    function mappingRow_html(row)
2987    {
2988        return ""
2989            + "<tr>"
2990            + "<td style='padding:4px 6px'>"+oCOM.escapeHTML(row.space)+"</td>"
2991            + "<td style='padding:4px 6px'>"+mappingRange_html(row.cpuFrom,row.cpuTo)+"</td>"
2992            + "<td style='padding:4px 6px'>"+mappingRange_html(row.offsetFrom,row.offsetTo)+"</td>"
2993            + "<td style='padding:4px 6px'>"+mappingOperations_html(row.operations)+"</td>"
2994            + "</tr>";
2995    }
2996
2997    function mappingRange_html(from,to,digits)
2998    {
2999        if(from===undefined || to===undefined) return "&mdash;";
3000        var range = digits===undefined
3001            ? fmtRange(from,to,0)
3002            : "$"+oCOM.getHexMulti(from,digits)+"-$"+oCOM.getHexMulti(to,digits);
3003
3004        return "<div class=\"appbut skinny\">"+range+"</div>";
3005    }
3006
3007    function mappingOperations_html(operations)
3008    {
3009        if(!operations || !operations.length) return "&mdash;";
3010
3011        var html = "<div style='display:flex;gap:3px;white-space:nowrap'>";
3012        for(var i=0;i<operations.length;i++)
3013        {
3014            var operation = operations[i];
3015            html += "<div class=\"appbut skinny\""
3016                + (operation.title ? " title=\""+oCOM.escapeHTML(operation.title)+"\"" : "")
3017                + (operation.enabled===false ? " style=\"opacity:.55\"" : "")
3018                + ">"+oCOM.escapeHTML(operation.op)+"</div>";
3019        }
3020        return html+"</div>";
3021    }
3022
3023    function mappingTooltip(mapping)
3024    {
3025        var parts = [];
3026        if(mapping.target) parts.push(mapping.op+" -> "+mapping.target);
3027        if(mapping.condition) parts.push(mapping.condition);
3028        if(mapping.enabled===false) parts.push("disabled by current soft-switch state");
3029        if(mapping.source) parts.push(mapping.source);
3030        return parts.join("; ");
3031    }
3032
3033    function deviceJSONClone(value,seen)
3034    {
3035        if(value===null || value===undefined) return value;
3036        if(typeof(value)=="string" || typeof(value)=="number" || typeof(value)=="boolean") return value;
3037        if(typeof(value)=="function" || typeof(value)=="symbol") return undefined;
3038
3039        if(typeof(ArrayBuffer)!="undefined" && ArrayBuffer.isView && ArrayBuffer.isView(value))
3040            return Array.from(value);
3041
3042        seen = seen || [];
3043        if(typeof(value)=="object")
3044        {
3045            if(seen.indexOf(value)>=0) return undefined;
3046            seen.push(value);
3047
3048            if(Array.isArray(value))
3049            {
3050                var arr=[];
3051                for(var i=0;i<value.length;i++)
3052                {
3053                    var item=deviceJSONClone(value[i],seen);
3054                    if(item!==undefined) arr.push(item);
3055                }
3056                seen.pop();
3057                return arr;
3058            }
3059
3060            var out={};
3061            for(var key in value)
3062            {
3063                var cloned=deviceJSONClone(value[key],seen);
3064                if(cloned!==undefined) out[key]=cloned;
3065            }
3066            seen.pop();
3067            return out;
3068        }
3069        return undefined;
3070    }
3071
3072    this.deviceMetadata = function(owner,deviceRef)
3073    {
3074        if(!owner) return null;
3075        var entry=null;
3076        var device=null;
3077        var refHash=typeof(deviceRef)==="number" ? deviceRef : NaN;
3078        var refCode=Number.isInteger(refHash) ? "" : String(deviceRef || "");
3079
3080        for(var key in this.attachments)
3081        {
3082            var candidate=this.attachments[key];
3083            var candidateDevice=candidate && candidate.device;
3084            if(!candidate || candidate.owner!==owner || !candidateDevice) continue;
3085
3086            if(Number.isInteger(refHash))
3087            {
3088                if(Number(candidateDevice.attach?.hash)!==refHash) continue;
3089            }
3090            else if(refCode && candidateDevice.id?.DCODE!==refCode) continue;
3091
3092            entry=candidate;
3093            device=candidateDevice;
3094            break;
3095        }
3096        if(!device) return null;
3097
3098        var id=device.id || {};
3099        var state;
3100        try
3101        {
3102            state=typeof(device.getState)=="function"
3103                ? device.getState()
3104                : device.state;
3105        }
3106        catch(e) { state=undefined; }
3107
3108        return {
3109             "DCODE":String(id.DCODE || refCode || "")
3110            ,"instanceID":Number(device.attach?.hash)
3111            ,"hostPCODE":String(id.hostPCODE || owner.id?.PCODE || "")
3112            ,"coID":String(id.coID || entry.info?.coID || "")
3113            ,"description":String(id.description || entry.info?.description || "")
3114            ,"id":deviceJSONClone(id) || {}
3115            ,"config":deviceJSONClone(entry.info || {}) || {}
3116            ,"attachment":deviceJSONClone(device.attach || {}) || {}
3117            ,"ports":deviceJSONClone(device.ports || {}) || {}
3118            ,"state":deviceJSONClone(state)
3119        };
3120    };
3121
3122    function devicePopupElement()
3123    {
3124        var popup=document.getElementById("deviceConfig_popup");
3125        if(popup) return popup;
3126
3127        popup=document.createElement("div");
3128        popup.id="deviceConfig_popup";
3129        popup.className="appbox";
3130        popup.hidden=true;
3131        popup.style.cssText="position:fixed;z-index:6;width:360px;max-width:42vw;padding:8px;text-align:left";
3132        (document.getElementById("feature_box") || document.body).appendChild(popup);
3133        return popup;
3134    }
3135
3136    function positionDevicePopup(popup)
3137    {
3138        var host=document.getElementById("slotConfig_popup");
3139        if(!popup || !host || typeof(host.getBoundingClientRect)!="function") return;
3140        var rect=host.getBoundingClientRect();
3141        var gap=8;
3142        var width=360;
3143        var left=rect.right+gap;
3144        if(typeof(window)!="undefined" && left+width>window.innerWidth-gap)
3145            left=Math.max(gap,rect.left-width-gap);
3146        popup.style.left=Math.round(left)+"px";
3147        popup.style.top=Math.max(gap,Math.round(rect.top))+"px";
3148    }
3149
3150    function devicePickerAnchorID(slotN)
3151    {
3152        return "device_add_"+Number(slotN);
3153    }
3154
3155    function devicePickerAnchor(slotN,active)
3156    {
3157        var anchor=document.getElementById(devicePickerAnchorID(slotN));
3158        if(!anchor) return;
3159        anchor.style.background=active ? "rgba(255,255,0,.5)" : "";
3160        anchor.setAttribute("aria-expanded",active ? "true" : "false");
3161    }
3162
3163    function clearDevicePickerAnchors()
3164    {
3165        var anchors=document.querySelectorAll("[id^='device_add_']");
3166        for(var i=0;i<anchors.length;i++)
3167        {
3168            anchors[i].style.background="";
3169            anchors[i].setAttribute("aria-expanded","false");
3170        }
3171    }
3172
3173    this.deviceConfig_close = function()
3174    {
3175        var popup=document.getElementById("deviceConfig_popup");
3176        clearDevicePickerAnchors();
3177        if(!popup) return false;
3178        popup.hidden=true;
3179        popup.innerHTML="";
3180        popup.removeAttribute("data-mode");
3181        popup.removeAttribute("data-slot");
3182        return true;
3183    };
3184
3185    this.devicePicker_message = function(text)
3186    {
3187        var el=document.getElementById("devicePicker_message");
3188        if(!el) return false;
3189        el.textContent=String(text || "");
3190        el.hidden=!text;
3191        return true;
3192    };
3193
3194    this.devicePicker_entries = function(owner)
3195    {
3196        var out=[];
3197        var cfg=owner && Array.isArray(owner.deviceConfig) ? owner.deviceConfig : [];
3198        var pcode=owner && owner.id ? owner.id.PCODE : "";
3199        for(var i=0;i<cfg.length;i++)
3200        {
3201            var info=cfg[i];
3202            if(!info || (info.hostPCODE && info.hostPCODE!==pcode)) continue;
3203            out.push(info);
3204        }
3205        return out;
3206    };
3207
3208    this.devicePicker_popup = function(slotN)
3209    {
3210        slotN=Number(slotN);
3211        var owner=this.SLOT2obj(slotN);
3212        if(!owner) return false;
3213
3214        var popup=devicePopupElement();
3215        if(!popup.hidden && popup.getAttribute("data-mode")==="picker" &&
3216           Number(popup.getAttribute("data-slot"))===slotN)
3217        {
3218            this.deviceConfig_close();
3219            return true;
3220        }
3221
3222        this.deviceConfig_close();
3223        var entries=this.devicePicker_entries(owner);
3224        var pcode=peripheralPCODE(owner) || owner.id?.PCODE || "PERIPHERAL";
3225        var description=peripheralDescription(owner);
3226        var slotTitle=slotN2name(slotN);
3227        var html="<button class='appbut' type='button' style='float:right' title='Close' aria-label='Close device picker' onclick=\"apple2plus.hwObj().io.deviceConfig_close()\">x</button>";
3228        html += "<div style='padding-right:28px'><b>ADD DEVICE TO "+oCOM.escapeHTML(pcode)+"</b> &mdash; "+oCOM.escapeHTML(slotTitle)+"</div>";
3229        if(description)
3230            html += "<div style='margin-top:2px;opacity:.75'>"+oCOM.escapeHTML(description)+"</div>";
3231        html += "<div id='devicePicker_message' role='status' hidden style='margin-top:8px;padding:6px'></div>";
3232        html += "<div style='margin-top:10px;display:flex;flex-direction:column;gap:4px'>";
3233
3234        if(!entries.length)
3235            html += "<div class='appbox' style='float:none;padding:8px'>No compatible devices declared.</div>";
3236        else
3237        {
3238            for(var i=0;i<entries.length;i++)
3239            {
3240                var info=entries[i];
3241                var dcode=String(info.DCODE || info.coID || "device");
3242                var devices=Array.isArray(owner.devices) ? owner.devices : [];
3243                var attachedCount=0;
3244                for(var d=0;d<devices.length;d++)
3245                    if(devices[d]?.id?.DCODE===dcode) attachedCount++;
3246
3247                var ctor=globalThis[info.coID];
3248                var available=typeof(ctor)==="function";
3249                var state=available
3250                    ? (attachedCount ? "Add another · Attached: "+attachedCount : "Available")
3251                    : "Unavailable";
3252                var title=info.description || dcode;
3253                if(attachedCount) title += " — "+attachedCount+" attached";
3254                if(typeof(ctor)!=="function") title += " — device constructor unavailable";
3255
3256                html += "<button class='appbut label device-picker-entry"+(available ? "" : " greyed")+"' type='button'"
3257                    + " style='width:100%;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:6px 8px;text-align:left;cursor:"+(available ? "pointer" : "default")+";'"
3258                    + " title='"+oCOM.escapeHTML(title)+"'"
3259                    + (available
3260                        ? " onclick=\"event.stopPropagation();apple2plus.hwObj().io.devicePicker_select("+slotN+",'"+dcode+"')\""
3261                        : " disabled aria-disabled='true'")
3262                    + ">"
3263                    + "<span style='min-width:0'>"
3264                    + "<span style='white-space:nowrap'><i class='"+slotDeviceIconClass(info.icon)+"'></i>&nbsp;<b>"+oCOM.escapeHTML(dcode)+"</b></span>"
3265                    + (info.description ? "<br><span style='font-size:90%;opacity:.78'>"+oCOM.escapeHTML(info.description)+"</span>" : "")
3266                    + "</span>"
3267                    + "<span style='white-space:nowrap'>"
3268                    + (available ? "<i class='fa fa-plus'></i>&nbsp;" : "")
3269                    + oCOM.escapeHTML(state)
3270                    + "</span>"
3271                    + "</button>";
3272            }
3273        }
3274        html += "</div>";
3275
3276        popup.innerHTML=html;
3277        popup.setAttribute("data-mode","picker");
3278        popup.setAttribute("data-slot",String(slotN));
3279        popup.hidden=false;
3280        devicePickerAnchor(slotN,true);
3281        positionDevicePopup(popup);
3282        return true;
3283    };
3284
3285    this.devicePicker_select = function(slotN,DCODE)
3286    {
3287        slotN=Number(slotN);
3288        DCODE=String(DCODE || "");
3289        var owner=this.SLOT2obj(slotN);
3290        if(!owner || !DCODE)
3291        {
3292            this.devicePicker_message("Could not attach device: invalid target.");
3293            return false;
3294        }
3295
3296        var entries=this.devicePicker_entries(owner);
3297        var info=null;
3298        for(var i=0;i<entries.length;i++)
3299            if(String(entries[i].DCODE || entries[i].coID || "")===DCODE) { info=entries[i]; break; }
3300        if(!info)
3301        {
3302            this.devicePicker_message("Could not attach device: it is not compatible with this peripheral.");
3303            return false;
3304        }
3305
3306        var device=null;
3307        try
3308        {
3309            device=this.attach(owner,info,{"newInstance":true});
3310        }
3311        catch(e)
3312        {
3313            console.error("Device attach failed",e);
3314        }
3315
3316        if(!device)
3317        {
3318            this.devicePicker_message("Could not attach device "+DCODE+".");
3319            return false;
3320        }
3321
3322        this.deviceConfig_close();
3323        this.slotConfig_refresh(slotN);
3324        this.refreshDeviceToolboxes({"id":"devices","default_slot":this.slot2ID(slotN)});
3325        return true;
3326    };
3327
3328    this.deviceConfig_detail = function(slotN,instanceHash)
3329    {
3330        slotN=Number(slotN);
3331        instanceHash=Number(instanceHash);
3332        var owner=this.SLOT2obj(slotN);
3333        var metadata=this.deviceMetadata(owner,instanceHash);
3334        if(!owner || !metadata) return false;
3335
3336        var liveDevice=null;
3337        var devices=Array.isArray(owner.devices) ? owner.devices : [];
3338        for(var i=0;i<devices.length;i++)
3339        {
3340            if(Number(devices[i]?.attach?.hash)===instanceHash)
3341            {
3342                liveDevice=devices[i];
3343                break;
3344            }
3345        }
3346        var unit=liveDevice && typeof(liveDevice.getUnit)==="function"
3347            ? Number(liveDevice.getUnit())
3348            : Number(liveDevice?.id?.deviceN);
3349        var bus=typeof(owner.getBus)==="function" ? owner.getBus() : null;
3350        var canMove=!!(bus && typeof(bus.move)==="function" && Number.isInteger(unit));
3351        var upDisabled=!canMove || unit<=1;
3352        var downDisabled=!canMove || unit>=8;
3353
3354        var popup=devicePopupElement();
3355        var description=metadata.description || "";
3356        var instanceLabel="#"+oCOM.getHexWord(metadata.instanceID);
3357        var html="<button class='appbut' type='button' style='float:right' title='Close' onclick=\"apple2plus.hwObj().io.deviceConfig_close()\">x</button>"
3358            + "<div style='padding-right:28px'><b>"+oCOM.escapeHTML(metadata.DCODE)+" "+instanceLabel+"</b>"
3359            + (description ? "<br>"+oCOM.escapeHTML(description) : "")
3360            + "</div><div style='margin-top:10px'>"
3361            + "<button class='appbut' type='button' title='Move device one unit up'"+(upDisabled ? " disabled" : " onclick=\"event.stopPropagation();apple2plus.hwObj().io.deviceConfig_move("+slotN+","+metadata.instanceID+",-1)\"")+"><i class='fa fa-arrow-up'></i></button>&nbsp;"
3362            + "<button class='appbut' type='button' title='Move device one unit down'"+(downDisabled ? " disabled" : " onclick=\"event.stopPropagation();apple2plus.hwObj().io.deviceConfig_move("+slotN+","+metadata.instanceID+",1)\"")+"><i class='fa fa-arrow-down'></i></button>&nbsp;"
3363            + "<button class='appbut' type='button' title='Detach device' onclick=\"event.stopPropagation();apple2plus.hwObj().io.deviceConfig_eject("+slotN+","+metadata.instanceID+")\"><i class='fa fa-eject'></i></button>"
3364            + "</div>";
3365
3366        popup.innerHTML=html;
3367        popup.hidden=false;
3368        positionDevicePopup(popup);
3369        return true;
3370    };
3371
3372    this.deviceConfig_move = function(slotN,instanceHash,delta)
3373    {
3374        slotN=Number(slotN);
3375        instanceHash=Number(instanceHash);
3376        delta=Number(delta);
3377        if(delta!==-1 && delta!==1) return false;
3378
3379        var owner=this.SLOT2obj(slotN);
3380        if(!owner || !Number.isInteger(instanceHash)) return false;
3381
3382        var devices=Array.isArray(owner.devices) ? owner.devices : [];
3383        var device=null;
3384        for(var i=0;i<devices.length;i++)
3385        {
3386            if(Number(devices[i]?.attach?.hash)===instanceHash)
3387            {
3388                device=devices[i];
3389                break;
3390            }
3391        }
3392        if(!device) return false;
3393
3394        var currentUnit=typeof(device.getUnit)==="function"
3395            ? Number(device.getUnit())
3396            : Number(device.id?.deviceN);
3397        var targetUnit=currentUnit+delta;
3398        if(!Number.isInteger(currentUnit) || targetUnit<1 || targetUnit>8) return false;
3399
3400        var bus=typeof(owner.getBus)==="function" ? owner.getBus() : null;
3401        if(!bus || typeof(bus.move)!=="function") return false;
3402
3403        try
3404        {
3405            if(bus.move(device,targetUnit)!==device) return false;
3406        }
3407        catch(e)
3408        {
3409            console.error("Device unit move failed",e);
3410            return false;
3411        }
3412
3413        if(typeof(owner.onDeviceTopologyChanged)==="function")
3414        {
3415            try
3416            {
3417                owner.onDeviceTopologyChanged({
3418                     "type":"move"
3419                    ,"DCODE":device.id?.DCODE || ""
3420                    ,"device":device
3421                    ,"instanceID":instanceHash
3422                    ,"fromUnit":currentUnit
3423                    ,"toUnit":targetUnit
3424                });
3425            }
3426            catch(e) { console.error("Device topology move notification failed",e); }
3427        }
3428
3429        this.slotConfig_refresh(slotN);
3430        this.refreshDeviceToolboxes({"id":"devices","default_slot":this.slot2ID(slotN)});
3431        this.deviceConfig_detail(slotN,instanceHash);
3432        return true;
3433    };
3434
3435    this.deviceConfig_download = function(slotN,instanceHash)
3436    {
3437        slotN=Number(slotN);
3438        instanceHash=Number(instanceHash);
3439        var owner=this.SLOT2obj(slotN);
3440        var metadata=this.deviceMetadata(owner,instanceHash);
3441        if(!metadata) return false;
3442        try
3443        {
3444            var name=(metadata.DCODE+"_"+oCOM.getHexWord(metadata.instanceID)+"_"+slotN2name(slotN).replace("#","")).replace(/[^A-Za-z0-9_.-]/g,"_")+".json";
3445            var json=JSON.stringify(metadata,null,2);
3446            oCOM.Download(name,new TextEncoder("utf-8").encode(json));
3447            return true;
3448        }
3449        catch(e)
3450        {
3451            console.error("Device JSON download failed",e);
3452            return false;
3453        }
3454    };
3455
3456    this.deviceConfig_downloadDevices = function(slotN)
3457    {
3458        slotN=Number(slotN);
3459        var owner=this.SLOT2obj(slotN);
3460        if(!owner) return false;
3461
3462        var devices=Array.isArray(owner.devices) ? owner.devices : [];
3463        var metadata=[];
3464        for(var i=0;i<devices.length;i++)
3465        {
3466            var hash=Number(devices[i]?.attach?.hash);
3467            if(!Number.isInteger(hash)) continue;
3468            var item=this.deviceMetadata(owner,hash);
3469            if(item) metadata.push(item);
3470        }
3471
3472        try
3473        {
3474            var pcode=peripheralPCODE(owner) || "peripheral";
3475            var slotName=slotN2name(slotN).replace("#","");
3476            var name=(pcode+"_devices_"+slotName).replace(/[^A-Za-z0-9_.-]/g,"_")+".json";
3477            var payload={
3478                 "hostPCODE":pcode
3479                ,"slot":slotN2name(slotN)
3480                ,"devices":metadata
3481            };
3482            var json=JSON.stringify(payload,null,2);
3483            oCOM.Download(name,new TextEncoder("utf-8").encode(json));
3484            return true;
3485        }
3486        catch(e)
3487        {
3488            console.error("Device collection JSON download failed",e);
3489            return false;
3490        }
3491    };
3492
3493    this.deviceConfig_eject = function(slotN,instanceHash)
3494    {
3495        slotN=Number(slotN);
3496        instanceHash=Number(instanceHash);
3497        var owner=this.SLOT2obj(slotN);
3498        if(!owner || !Number.isInteger(instanceHash)) return false;
3499        if(!this.detachInstance(owner,instanceHash)) return false;
3500
3501        this.deviceConfig_close();
3502        this.slotConfig_refresh(slotN);
3503        this.refreshDeviceToolboxes({"id":"devices","default_slot":this.slot2ID(slotN)});
3504        return true;
3505    };
3506
3507    /*
3508     * Render the live child devices attached to one peripheral.
3509     *
3510     * Use peripheral.devices rather than deviceConfig: Apple2IO.attach()
3511     * populates peripheral.devices only after a child device was actually
3512     * instantiated and attached.
3513     */
3514    function slotDeviceTable_html(peripheral)
3515    {
3516        var devices = peripheral && Array.isArray(peripheral.devices)
3517            ? peripheral.devices.slice()
3518            : [];
3519        devices.sort(function(a,b)
3520        {
3521            var au=typeof(a?.getUnit)==="function" ? Number(a.getUnit()) : Number(a?.id?.deviceN);
3522            var bu=typeof(b?.getUnit)==="function" ? Number(b.getUnit()) : Number(b?.id?.deviceN);
3523            if(!Number.isInteger(au)) au=99;
3524            if(!Number.isInteger(bu)) bu=99;
3525            return au-bu;
3526        });
3527        var body = "";
3528
3529        for(var i=0;i<devices.length;i++)
3530        {
3531            var device = devices[i];
3532            if(!device) continue;
3533
3534            body += ""
3535                + "<tr>"
3536                + "<td style='padding:4px 6px;vertical-align:top'>"
3537                + slotDeviceLabel_html(peripheral,device)
3538                + "</td>"
3539                + "<td style='padding:4px 6px;vertical-align:top;white-space:nowrap'>"
3540                + deviceInstanceLabel_html(device)
3541                + "</td>"
3542                + "<td style='padding:4px 6px;vertical-align:top'>"
3543                + slotDevicePorts_html(device)
3544                + "</td>"
3545                + "</tr>";
3546        }
3547
3548        if(!body)
3549            body = "<tr><td colspan='3' style='padding:8px'>No devices attached.</td></tr>";
3550
3551        var slotN=peripheral && peripheral.mount ? Number(peripheral.mount.slotN) : -1;
3552        var declaredDevices=io.devicePicker_entries(peripheral);
3553        var canPick=declaredDevices.length>0;
3554        var pcode=peripheralPCODE(peripheral) || peripheral?.id?.PCODE || "PERIPHERAL";
3555        var deviceHeader="<div style='margin-top:10px;margin-bottom:3px;display:flex;align-items:center;gap:4px'>"
3556            + "<span style='margin-right:4px'><b>"+oCOM.escapeHTML(pcode)+"</b> &mdash; devices</span>";
3557        if(Number.isInteger(slotN) && slotN>=0)
3558        {
3559            deviceHeader += "<button class='appbut' type='button' title='Download device JSON' aria-label='Download device JSON'"
3560                + " onclick=\"event.stopPropagation();apple2plus.hwObj().io.deviceConfig_downloadDevices("+slotN+")\">"
3561                + "<i class='fa fa-cloud-download-alt'></i></button>"
3562                + "<button class='appbut' type='button' id='"+devicePickerAnchorID(slotN)+"'"
3563                + " aria-label='Attach device' aria-haspopup='dialog' aria-expanded='false'"
3564                + " title='"+(canPick ? "Attach device" : "No compatible devices declared")+"'"
3565                + (canPick
3566                    ? " onclick=\"event.stopPropagation();apple2plus.hwObj().io.devicePicker_popup("+slotN+")\""
3567                    : " disabled")
3568                + "><i class='fa fa-plus'></i></button>";
3569        }
3570        deviceHeader += "</div>";
3571
3572        return deviceHeader
3573            + "<table style='width:100%;border-collapse:collapse;margin-top:0px;text-align:left'>"
3574            + "<thead><tr style='border-bottom:1px solid #888'>"
3575            + "<th style='padding:4px 6px'>Device</th>"
3576            + "<th style='padding:4px 6px'>Instance</th>"
3577            + "<th style='padding:4px 6px'>Ports</th>"
3578            + "</tr></thead>"
3579            + "<tbody>"+body+"</tbody>"
3580            + "</table>";
3581    }
3582
3583    /*
3584     * Match the labels used by Peripheral controls: appbut + label,
3585     * pictogram, DCODE and description/range in the tooltip.
3586     */
3587    function slotDeviceLabel_html(peripheral,device)
3588    {
3589        var id=device && device.id ? device.id : {};
3590        var deviceCode=String(
3591            id.DCODE ||
3592            id.coID ||
3593            (device && device.constructor && device.constructor.name) ||
3594            "device"
3595        );
3596        var description=id.description ? String(id.description) : "";
3597        var icon=slotDeviceIconClass(id.icon);
3598        var instanceHash=Number(device && device.attach ? device.attach.hash : NaN);
3599
3600        return ""
3601            + "<div class='appbut label'"
3602            + " data-dcode='"+oCOM.escapeHTML(deviceCode)+"'"
3603            + (Number.isInteger(instanceHash) ? " data-instance='"+instanceHash+"'" : "")
3604            + " style='display:inline-block;cursor:pointer;white-space:nowrap;'"
3605            + (peripheral && peripheral.mount && Number.isInteger(instanceHash)
3606                ? " onclick='event.stopPropagation();apple2plus.hwObj().io.deviceConfig_detail("+Number(peripheral.mount.slotN)+","+instanceHash+")'"
3607                : "")
3608            + (description
3609                ? " title=\""+oCOM.escapeHTML(description)+"\""
3610                : "")
3611            + ">"
3612            + "<i class=\""+icon+"\" aria-hidden=\"true\"></i>&nbsp;"
3613            + oCOM.escapeHTML(deviceCode)
3614            + "</div>";
3615    }
3616
3617    function deviceInstanceLabel_html(device)
3618    {
3619        var hash=Number(device && device.attach ? device.attach.hash : NaN);
3620        return Number.isInteger(hash) ? "#"+oCOM.getHexWord(hash) : "&mdash;";
3621    }
3622
3623    /*
3624     * Font Awesome icon identifiers are CSS class lists such as
3625     * "fa fa-keyboard".  Do not pass the complete class list through
3626     * oCOM.escapeHTML(): that helper intentionally converts spaces to
3627     * &nbsp;, which turns the list into one invalid CSS class token.
3628     *
3629     * Instead, accept only ordinary CSS class-name characters in each token
3630     * and join the validated tokens with real spaces.
3631     */
3632    function slotDeviceIconClass(value)
3633    {
3634        var tokens = String(value || "fa fa-cube")
3635            .trim()
3636            .split(/\s+/)
3637            .filter(function(token)
3638            {
3639                return /^[A-Za-z0-9_-]+$/.test(token);
3640            });
3641
3642        return tokens.length ? tokens.join(" ") : "fa fa-cube";
3643    }
3644
3645    function slotDevicePorts_html(device)
3646    {
3647        var ports = device && device.ports;
3648        if(!ports || typeof(ports)!="object")
3649            return "&mdash;";
3650
3651        var names = Object.keys(ports).filter(function(name)
3652        {
3653            var port = ports[name] || {};
3654            return String(port.visibility || "public").toLowerCase()!="internal";
3655        });
3656        if(!names.length)
3657            return "&mdash;";
3658
3659        var html = "<div style='display:flex;flex-wrap:wrap;gap:3px'>";
3660
3661        for(var i=0;i<names.length;i++)
3662        {
3663            var name = names[i];
3664            var port = ports[name] || {};
3665            var label = slotDevicePortLabel(name,port);
3666            var description = port.description ? String(port.description) : "";
3667
3668            html += ""
3669                + "<div class=\"appbut label\""
3670                + " style=\"display:inline-block;cursor:default;white-space:nowrap;"
3671                + "background:rgba(192,192,192,.35);\""
3672                + (description
3673                    ? " title=\""+oCOM.escapeHTML(description)+"\""
3674                    : "")
3675                + ">"
3676                + oCOM.escapeHTML(label)
3677                + "</div>";
3678        }
3679
3680        return html+"</div>";
3681    }
3682
3683    function slotDevicePortLabel(name,port)
3684    {
3685        port = port || {};
3686
3687        /*
3688         * Structured bus/device ports provide their own human-facing label and
3689         * runtime instance selector.  Keep transport details such as protocol,
3690         * direction and MIME metadata available in JSON without crowding the
3691         * compact Device-table chip.
3692         */
3693        if(port.label!==undefined || port.unit!==undefined)
3694        {
3695            var label = port.label===undefined || port.label===null || String(port.label)===""
3696                ? String(name)
3697                : String(port.label);
3698            var structured = [label];
3699            if(port.unit!==undefined && port.unit!==null && port.unit!=="")
3700                structured.push("Unit "+String(port.unit));
3701            return structured.join(" · ");
3702        }
3703
3704        /* Legacy port metadata keeps its existing direction/MIME rendering. */
3705        var parts = [String("<b>"+name+"</b>")];
3706
3707        if(port.direction)
3708            parts.push(String(port.direction.toUpperCase()));
3709
3710        if(port.mime)
3711        {
3712            var mime = Array.isArray(port.mime)
3713                ? port.mime.join(", ")
3714                : String(port.mime);
3715            if(mime) parts.push(mime);
3716        }
3717
3718       return parts.join("·");
3719    }
3720
3721    /*
3722     * Render the address spaces and callbacks of one mounted peripheral.
3723     *
3724     * mount.ranges contains offsets relative to the Apple II I/O base,
3725     * normally $C000. peripheral.action contains the actual RD and WR
3726     * callback functions; these functions cannot be displayed through
3727     * JSON.stringify().
3728     */
3729    /*
3730     * Kept temporarily as a legacy renderer for comparison/debugging.
3731     *
3732     * It must not use the slotConfigDetail_html name: a later function
3733     * declaration with that name overrides the compact renderer above.
3734     */
3735    function slotConfigDetail_legacy_html(slot)
3736    {
3737        var p = slot && slot.peripheral;
3738
3739        if(!p)
3740        {
3741            return ""
3742                + "<div class=appbox "
3743                + "style='margin-top:8px;padding:8px'>"
3744                + "No peripheral mounted."
3745                + "</div>";
3746        }
3747
3748        /*
3749         * This order also follows the CPU-visible Apple II address space:
3750         *
3751         * HostIO  : $C000-$C07F
3752         * SlotIO  : $C080-$C0FF
3753         * SlotROM : $C100-$C7FF
3754         * HostROM : $C800-$CFFF
3755         */
3756        var rangeNames = [
3757             "HostIO"
3758            ,"SlotIO"
3759            ,"SlotROM"
3760            ,"HostROM"
3761        ];
3762
3763        var rows = "";
3764
3765        for(var i=0;i<rangeNames.length;i++)
3766        {
3767            var rangeName = rangeNames[i];
3768            var range = peripheralRange(p,rangeName);
3769
3770            if(!range) continue;
3771
3772            var action =
3773                p.action && p.action[rangeName]
3774                    ? p.action[rangeName]
3775                    : {};
3776
3777            rows += ""
3778                + "<tr>"
3779
3780                + "<td style='padding:4px 8px'>"
3781                + "<b>"+escapeHTML(rangeName)+"</b>"
3782                + "</td>"
3783
3784                + "<td style='padding:4px 8px;font-family:monospace'>"
3785                + fmtRange(range.from,range.to,0)
3786                + "</td>"
3787
3788                + "<td style='padding:4px 8px;font-family:monospace'>"
3789                + fmtRange(range.from,range.to,ioBase())
3790                + "</td>"
3791
3792                + "<td style='padding:4px 8px;font-family:monospace'>"
3793                + actionCallbackLabel(action.RD)
3794                + "</td>"
3795
3796                + "<td style='padding:4px 8px;font-family:monospace'>"
3797                + actionCallbackLabel(action.WR)
3798                + "</td>"
3799
3800                + "</tr>";
3801        }
3802
3803        if(rows=="")
3804        {
3805            rows = ""
3806                + "<tr>"
3807                + "<td colspan=5 style='padding:8px'>"
3808                + "No address ranges are configured for this peripheral."
3809                + "</td>"
3810                + "</tr>";
3811        }
3812
3813        var description = peripheralDescription(p);
3814
3815        return ""
3816            + "<div class=appbox "
3817            + "style='width:650px;max-width:80vw;"
3818            + "overflow:auto;margin-top:8px;padding:8px'>"
3819
3820            + "<div>"
3821            + "<b>"+escapeHTML(peripheralPCODE(p))+"</b>"
3822            + (
3823                description
3824                    ? " &mdash; "+escapeHTML(description)
3825                    : ""
3826              )
3827            + "</div>"
3828
3829            + "<table "
3830            + "style='width:100%;border-collapse:collapse;"
3831            + "margin-top:8px;text-align:left'>"
3832
3833            + "<thead>"
3834            + "<tr style='border-bottom:1px solid #888'>"
3835            + "<th style='padding:4px 8px'>Space</th>"
3836            + "<th style='padding:4px 8px'>I/O offset</th>"
3837            + "<th style='padding:4px 8px'>CPU address</th>"
3838            + "<th style='padding:4px 8px'>RD function</th>"
3839            + "<th style='padding:4px 8px'>WR function</th>"
3840            + "</tr>"
3841            + "</thead>"
3842
3843            + "<tbody>"
3844            + rows
3845            + "</tbody>"
3846
3847            + "</table>"
3848            + "</div>";
3849    }
3850
3851    function actionCallbackLabel(action)
3852    {
3853        var fn = action && action.callback;
3854
3855        if(typeof fn !== "function")
3856            return "&mdash;";
3857
3858        /*
3859         * Function.bind() normally produces names such as
3860         * "bound read" and "bound write". The "bound" prefix is
3861         * an implementation detail and is not useful in the UI.
3862         */
3863        var name = callbackLabel(fn).replace(/^bound\s+/,"");
3864
3865        return oCOM.escapeHTML(
3866            name == "(anonymous)"
3867                ? name
3868                : name+"()"
3869        );
3870    }
3871
3872    /////////////////////////////////////////////////////////////////
3873
3874
3875
3876    this.slotPicker_popup = function(ctx, slotTitle) 
3877    {
3878        var popupId = "slotConfig_popup";
3879        var anchorId = "d_slot_" + ctx.hostId + "_" + slotTitle.replace(/[^a-zA-Z0-9_-]/g, "_");
3880        document.getElementById(anchorId).style = "background:rgba(255, 255, 0, 0.5); border-radius:10px"
3881        
3882        var close   = "<button class='appbut' style='float:right' onclick=\"oCOM.POPUP.toggle('" + popupId + "');document.getElementById('"+anchorId+"').style=''\">x</button>";
3883        var html = "";
3884        
3885        html += "<div style=\"float:left\">ADD PERIPHERAL</div>";
3886        html += close;
3887        html += "<br><br>";
3888
3889        var io = apple2plus.hwObj().io;
3890
3891        console.assert(
3892            Array.isArray(io.slots),
3893            "io.slots must be an array"
3894        );
3895
3896        console.assert(
3897            io.slot_ctx.peripheral_slots.slots === io.slots,
3898            "slot renderer must reference the canonical slot array"
3899        );
3900
3901        console.assert(
3902            io.slots.length === slot_count + 1,
3903            "unexpected number of internal slot records"
3904        );
3905
3906        var slotN = slotName2n(slotTitle);
3907        var targetSlot = io.slots[slotN];
3908        var slotFit = slotR.slotFit && Array.isArray(slotR.slotFit[slotN])
3909            ? slotR.slotFit[slotN]
3910            : [];
3911        var names = io.scanPeripheralContainers();
3912        var pcodes = Object.keys(names).sort();
3913        
3914        html += "<div style='display:flex;flex-wrap:wrap;gap:2px'>";
3915
3916        for(var i=0;i<pcodes.length;i++)        
3917        {
3918            var id = names[pcodes[i]];
3919            var cinfo = typeof(_CFG_PSLOT)!="undefined" ? _CFG_PSLOT[id.PCODE] : null;
3920            var compatible = slotFit.indexOf(id.PCODE)>=0;
3921            var addable = !!(
3922                targetSlot &&
3923                !targetSlot.lock &&
3924                !targetSlot.peripheral &&
3925                id.slotLock!==true &&
3926                compatible &&
3927                cinfo
3928            );
3929            var title = id.description || id.PCODE;
3930
3931            if(id.slotLock===true)
3932                title += " — fixed peripheral";
3933            else if(!compatible)
3934                title += " — not compatible with "+slotTitle;
3935
3936            html += "<div class='appbut label"+(addable ? "" : " greyed")+"'"
3937                +" style='cursor:default;white-space:nowrap;'"
3938                +" title='"+oCOM.escapeHTML(title)+"'>"
3939                +"<button class='appbut skinny' type='button'"
3940                +(addable
3941                    ? " title='Add to "+slotTitle+"' onclick=\"event.stopPropagation();apple2plus.hwObj().io.slotPicker_select('" + ctx.hostId + "','" + slotTitle + "','" + id.PCODE + "')\""
3942                    : " disabled")
3943                +"><i class='fa fa-plus'></i></button>&nbsp;"
3944                +"<i class='" + (id.icon || "fa fa-cube") + "'></i>&nbsp;"
3945                +oCOM.escapeHTML(id.PCODE)
3946                +"</div>";
3947        }
3948        html += "</div>";
3949
3950        
3951
3952        /*
3953        // TODO: LOOKUP WHICH PERIPHERAL IS IN CURRENTLY IN 'slotTitle'
3954
3955        html += "<button class=appbut>"
3956            +"<a href=\"https://github.com/RetroAppleJS/RetroAppleJS.github.io/blob/main/docs/CONFIG.md#peripherals-list\" target=_blank> "
3957            +"<i class='fa fa-info-circle'></i>"
3958            +"</a>"
3959            +"</button> ";
3960        */
3961
3962
3963        document.getElementById(popupId).innerHTML = html;
3964
3965        if(oCOM.POPUP.get_state(popupId)==false) document.getElementById(anchorId).style = "";
3966        oCOM.POPUP.toggle(popupId);
3967    };
3968
3969    this.slotPicker_select = function(hostId, slotTitle, pcode)
3970    {
3971        var ctx = this.slot_ctx[hostId];
3972        var slots = ctx && Array.isArray(ctx.slots) ? ctx.slots : this.slots;
3973        var slot = slots.find(function(s) { return s.slotTitle === slotTitle; });
3974        if (!slot || slot.lock || slot.peripheral) return false;
3975
3976        var io = oEMU.component.IO.self;
3977        var discovered = io ? io.scanPeripheralContainers() : {};
3978        var id = discovered[pcode] || null;
3979        
3980        var cinfo = typeof(_CFG_PSLOT) !== "undefined" && _CFG_PSLOT[pcode] ? _CFG_PSLOT[pcode] : null;
3981        var slotN = slotName2n(slotTitle);
3982        var slotFit = slotR.slotFit && Array.isArray(slotR.slotFit[slotN])
3983            ? slotR.slotFit[slotN]
3984            : [];
3985        if(!id || !cinfo || id.slotLock===true || slotFit.indexOf(pcode)<0)
3986            return false;
3987
3988        var o = io.mount(cinfo,id,slotN,slotFit);
3989        if(!o || !o.pObj) return false;
3990
3991        for(var so in o.sInfo) slot[so] = o.sInfo[so];
3992        slot.peripheral = o.pObj;
3993
3994        var model = typeof(EMU_system_get)=="function" ? EMU_system_get() : "A2P";
3995        io.provisionPeripheral(o.pObj,model);
3996        refillEmptyIOActions();
3997
3998        if(typeof(o.pObj.restart)=="function")
3999            o.pObj.restart();
4000
4001        oCOM.POPUP.toggle("slotConfig_popup");
4002        this.slotsRender(hostId);
4003
4004        if(typeof(this.refreshDeviceToolboxes)=="function")
4005            this.refreshDeviceToolboxes({"id":"devices"});
4006
4007        return true;
4008    };
4009
4010    this.slotPicker_info = function() {
4011        oCOM.POPUP.wipe();
4012        oCOM.POPUP.html(
4013            "<b>Peripheral configuration</b><br><br>" +
4014            "To make a peripheral appear in this picker, add or modify its " +
4015            "entry in <code>CONFIG.md</code>.<br><br>" +
4016            "After editing <code>CONFIG.md</code>, compile it into " +
4017            "<code>COM_CONFIG.js</code> with:<br><br>" +
4018            "<code>tools/ConfigFile_updater.html</code><br><br>" +
4019            "Do not edit <code>COM_CONFIG.js</code> manually."
4020        );
4021    };
4022
4023// END
4024// ^ ^ ^ ^
4025// | | | |
4026//      ______   __          _                               ___  _          
4027//    .' ____ \ [  |        / |_                           .' ..](_)         
4028//    | (___ \_| | |  .--. `| |-'  .---.   .--.   _ .--.  _| |_  __   .--./) 
4029//     _.____`.  | |/ .'`\ \| |   / /'`\]/ .'`\ \[ `.-. |'-| |-'[  | / /'`\; 
4030//    | \____) | | || \__. || |,  | \__. | \__. | | | | |  | |   | | \ \._// 
4031//     \______.'[___]'.__.' \__/  '.___.' '.__.' [___||__][___] [___].',__`  
4032//                                                                  ( ( __)) 
4033
4034
4035
4036
4037
4038
4039
4040
4041
4042
4043
4044    this.deviceBtn = function(arg)
4045    {
4046        var btn = document.getElementById(arg.id);
4047        if(btn == null) return;
4048
4049        // First reconcile the UI with the currently mounted topology.
4050        var slot = this.refreshDeviceToolboxes(arg);
4051        var slots = this.deviceSlots();
4052
4053        if(slots.length == 0) return;
4054        if(arg.init === true) return;
4055
4056        if(slot == null || slots.indexOf(slot) < 0) slot = slots[0];
4057        else slot = slots[(slots.indexOf(slot)+1)%slots.length];
4058
4059        this.refreshDeviceToolboxes({"id":arg.id,"default_slot":slot});       
4060    }
4061
4062    this.deviceSlots = function()
4063    {
4064        var slots = [];
4065        for(var slotN=0;slotN<this.slots.length;slotN++)
4066        {
4067            if(this.SLOT2obj(slotN))
4068                slots.push(this.slot2ID(slotN));
4069        }
4070        return slots;
4071    }
4072
4073    this.deviceLabel = function(slot)
4074    {
4075        return slot==="H" ? "H▹" : slot + "▹";
4076    }
4077
4078    this.deviceTopologySig = function()
4079    {
4080        var slots = this.deviceSlots();
4081        var sig = [];
4082
4083        for(var i=0;i<slots.length;i++)
4084        {
4085            var s = slots[i];
4086            var slotN = slotID2n(s);
4087            var peripheral = this.SLOT2obj(slotN);
4088            var pcode = peripheralPCODE(peripheral);
4089            var instance = peripheral && peripheral.mount ? peripheral.mount.hash : "";
4090            var deviceCodes = [];
4091            var devices = peripheral && Array.isArray(peripheral.devices)
4092                ? peripheral.devices
4093                : [];
4094
4095            for(var d=0;d<devices.length;d++)
4096            {
4097                var device=devices[d];
4098                var id=device && device.id;
4099                if(id && id.DCODE)
4100                {
4101                    var unit=typeof(device.getUnit)==="function" ? Number(device.getUnit()) : Number(id.deviceN);
4102                    var hash=Number(device.attach?.hash);
4103                    deviceCodes.push(String(id.DCODE)+"#"+(Number.isInteger(hash
4103)?hash:"")+"@"+(Number.isInteger(unit)?unit:""));
4104                }
4105            }
4106
4107            sig.push(String(s)+":"+pcode+":"+instance+":"+deviceCodes.join(","));
4108        }
4109
4110        return sig.join("|");
4111    }
4112
4113    this.refreshDeviceToolboxes = function(arg)
4114    {
4115        arg = arg || {};
4116        var io = this;
4117
4118        function syncPeripheralLayoutContext(selectedSlot)
4119        {
4120            var layout = typeof(oLAYOUT) != "undefined" && oLAYOUT
4121                ? oLAYOUT
4122                : (typeof(window) != "undefined" ? window.oLAYOUT : null);
4123            if(!layout || typeof(layout.setPeripheralContext) != "function") return false;
4124
4125            if(selectedSlot === "H" || selectedSlot === null || selectedSlot === undefined)
4126                return layout.setPeripheralContext(null,"");
4127
4128            var slotN = Number(slotID2n(selectedSlot));
4129            var peripheral = Number.isInteger(slotN) ? io.SLOT2obj(slotN) : null;
4130            var pcode = peripheralPCODE(peripheral);
4131
4132            // Navigation is authoritative: rebuild the selected scene from the
4133            // live peripheral and its attached devices before presenting it.
4134            if(peripheral && typeof(io.syncDeviceLayout) == "function")
4135                io.syncDeviceLayout(peripheral);
4136            if(peripheral && typeof(peripheral.syncLayoutVisuals) == "function")
4137                peripheral.syncLayoutVisuals();
4138
4139            return layout.setPeripheralContext(slotN,pcode);
4140        }
4141
4142        var box = document.getElementById("device_toolbox_body");
4143        var btn = document.getElementById(arg.id || "devices");
4144        if(box == null || btn == null) return null;
4145
4146        var slots = this.deviceSlots();
4147        var sig = this.deviceTopologySig();
4148        var oldSig = box.getAttribute("data-topology");
4149
4150        // Rebuild only when mounted devices changed.
4151        if(oldSig !== sig)
4152        {
4153            // display all Peripheral controls
4154            var slots = this.deviceSlots();
4155            for(var slotN=0,html_arr=[];slotN<slots.length;slotN++)
4156                html_arr.push(this.deviceToolSlotHTML( slots[slotN] ));
4157            
4158            box.innerHTML = html_arr.join("");
4159            box.setAttribute("data-topology",sig);
4160
4161            if(typeof(EMU_mem_map)=="function" && typeof(apple2plus)=="object" && apple2plus!=null)
4162                EMU_mem_map();
4163        }
4164
4165        if(slots.length == 0)
4166        {
4167            btn.innerHTML = "∅";
4168            btn.setAttribute("data-slot","");
4169            this.showDeviceTool(null);
4170            syncPeripheralLayoutContext(null);
4171            return null;
4172        }
4173
4174        var cur = btn.getAttribute("data-slot");
4175        var slot = cur==="H" ? "H"
4176                 : (cur==null || cur==="" ? null : Number(cur));
4177
4178        // default_slot explicitly requested slot if still mounted
4179        if(arg.default_slot === "H" && slots.indexOf("H") >= 0) slot = "H";
4180        else if(typeof(arg.default_slot)=="number" && slots.indexOf(arg.default_slot) >= 0) slot = arg.default_slot;
4181        else if(slot == null || slots.indexOf(slot) < 0) slot = slots[0];
4182
4183        btn.setAttribute("data-slot", slot==="H" ? "H" : String(slot));
4184        btn.innerHTML = this.deviceLabel(slot);
4185        this.showDeviceTool(slot);
4186        syncPeripheralLayoutContext(slot);
4187        return slot;
4188    }
4189
4190    this.showDeviceTool = function(slot)
4191    {
4192        var all = document.querySelectorAll("[id^='device_tool_']");
4193        for(var i=0;i<all.length;i++)
4194            all[i].hidden = true;
4195
4196        if(slot == null) return;
4197
4198        var el = document.getElementById("device_tool_" + (slot==="H" ? "H" : slot));
4199        if(el) el.hidden = false;
4200    }
4201
4202    this.deviceToolSlotHTML = function(slotID)
4203    {
4204        var slotN = slotID2n(slotID);
4205
4206        var peripheral = this.SLOT2obj(slotN);
4207        var pcode = peripheralPCODE(peripheral);
4208        var model = typeof(EMU_system_get)=="function" ? EMU_system_get() : "A2P";
4209        var ctx =
4210        {
4211             "io":this
4212            ,"peripheral":peripheral
4213            ,"slotN":slotN
4214            ,"slotID":slotID
4215            ,"model":model
4216            ,"toolboxID":"device_tool_"+slotID
4217            ,"devices":peripheral && Array.isArray(peripheral.devices)
4218                ? peripheral.devices
4219                : []
4220        };
4221
4222        if(peripheral && typeof(peripheral.deviceToolSlotHTML)=="function")
4223        {
4224            try
4225            {
4226                var html = peripheral.deviceToolSlotHTML(ctx);
4227                if(typeof(html)=="string" && html.length>0) return html;
4228            }
4229            catch(e)
4230            {
4231                console.error(
4232                    "Peripheral toolbox failed for "+(pcode || slotID),
4233                    e
4234                );
4235            }
4236         }
4237 
4238        var label = pcode || "EMPTY";
4239        var description = peripheralDescription(peripheral);
4240
4241
4242        return ""
4243            + "<div class=toolbox id=\"device_tool_"+slotID+"\" hidden>"
4244            + "  <div class=appbox style=\"text-align:left;height:63px;padding:0px 6px 0px 6px;\">"
4245            + "    <b>#"+slotID+" "+oCOM.escapeHTML(label)+"</b><br>"
4246            +      (description ? oCOM.escapeHTML(description)+"<br>" : "")
4247            + "    No toolbox is available yet."
4248            + "  </div>"
4249            + "</div>";
4250    }
4251
4252
4253    function actionMapEntryCount(map)
4254    {
4255        var rows = [];
4256        var ops = orderedActionOps(map);
4257
4258        for(var oi=0;oi<ops.length;oi++)
4259        {
4260            var op = ops[oi];
4261            var opMap = map[op];
4262            var addrs = numericActionAddrs(opMap);
4263            var spans = buildActionMapSpans(op,opMap);
4264            var callbacks = [];
4265
4266            for(var ai=0;ai<addrs.length;ai++)
4267                if(callbacks.indexOf(opMap[addrs[ai]]) < 0) callbacks.push(opMap[addrs[ai]]);
4268
4269            rows.push({
4270                 "op": op
4271                ,"entries": addrs.length
4272                ,"spans": spans.length
4273                ,"callbacks": callbacks.length
4274                ,"relative": addrs.length ? fmtRange(addrs[0],addrs[addrs.length-1],0) : ""
4275                ,"absolute": addrs.length ? fmtRange(addrs[0],addrs[addrs.length-1],ioBase()) : ""
4276            });
4277        }
4278        return rows;
4279    }
4280
4281    function actionMapSpanReport(map)
4282    {
4283        var rows = [];
4284        var ops = orderedActionOps(map);
4285
4286        for(var oi=0;oi<ops.length;oi++)
4287        {
4288            var spans = buildActionMapSpans(ops[oi],map[ops[oi]]);
4289            for(var si=0;si<spans.length;si++)
4290            {
4291                var span = spans[si];
4292                var space = ioSpaceLabel(span.from,span.to);
4293                var meta = callbackMeta(span.callback);
4294                if(!meta.PCODE && space === "HostIO")
4295                    meta = {"slotTitle":"board","PCODE":"BOARD","range":"HostIO"};
4296
4297                rows.push({
4298                     "op": span.op
4299                    ,"relative": fmtRange(span.from,span.to,0)
4300                    ,"absolute": fmtRange(span.from,span.to,ioBase())
4301                    ,"bytes": span.to-span.from+1
4302                    ,"space": space
4303                    ,"slot": meta.slotTitle
4304                    ,"PCODE": meta.PCODE
4305                    ,"range": meta.range
4306                    ,"callback": callbackLabel(span.callback)
4307                });
4308            }
4309        }
4310        return rows;
4311    }
4312
4313    function slotConfigReport(cfg)
4314    {
4315        var rows = [];
4316        for(var i=0;i<cfg.length;i++)
4317        {
4318            var slot = cfg[i] || {};
4319            var p = slot.peripheral || null;
4320            rows.push({
4321                 "slot": slot.slotTitle || ""
4322                ,"lock": slot.lock ? "yes" : ""
4323                ,"PCODE": peripheralPCODE(p)
4324                ,"container": peripheralCoID(p)
4325                ,"HostROM": reportRange(peripheralRange(p,"HostROM"))
4326                ,"SlotIO": reportRange(peripheralRange(p,"SlotIO"))
4327                ,"SlotROM": reportRange(peripheralRange(p,"SlotROM"))
4328                ,"description": peripheralDescription(p)
4329            });
4330        }
4331        return rows;
4332    }
4333
4334    function buildActionMapSpans(op,opMap)
4335    {
4336        var addrs = numericActionAddrs(opMap);
4337        var rows = [];
4338        if(addrs.length==0) return rows;
4339
4340        var from = addrs[0];
4341        var prev = addrs[0];
4342        var callback = opMap[from];
4343
4344        for(var i=1;i<addrs.length;i++)
4345        {
4346            var addr = addrs[i];
4347            var nextCallback = opMap[addr];
4348
4349            if(addr == prev+1 && nextCallback === callback)
4350            {
4351                prev = addr;
4352                continue;
4353            }
4354
4355            rows.push({"op":op,"from":from,"to":prev,"callback":callback});
4356            from = prev = addr;
4357            callback = nextCallback;
4358        }
4359        rows.push({"op":op,"from":from,"to":prev,"callback":callback});
4360        return rows;
4361    }
4362
4363    function numericActionAddrs(opMap)
4364    {
4365        if(!opMap || typeof opMap !== "object") return [];
4366        return Object.keys(opMap)
4367            .map(function(k){ return Number(k) })
4368            .filter(function(n){ return Number.isFinite(n) })
4369            .sort(function(a,b){ return a-b });
4370    }
4371
4372    function orderedActionOps(map)
4373    {
4374        if(!map || typeof map !== "object") return [];
4375        var order = ["RD","WR","RR","BT","RG","SV","VA"];
4376        return Object.keys(map)
4377            .filter(function(op){ return map[op] && typeof map[op] === "object" })
4378            .sort(function(a,b)
4379            {
4380                var ai = order.indexOf(a); if(ai<0) ai = order.length;
4381                var bi = order.indexOf(b); if(bi<0) bi = order.length;
4382                return ai == bi ? a.localeCompare(b) : ai-bi;
4383            });
4384    }
4385
4386    function callbackMeta(callback)
4387    {
4388        var m = callback && callback._ioReport;
4389        if(m) return m;
4390
4391        return {
4392             "slotTitle": ""
4393            ,"PCODE": ""
4394            ,"range": ""
4395        };
4396    }
4397
4398    function callbackLabel(callback)
4399    {
4400        if(typeof callback !== "function") return String(callback);
4401        return callback.name || "(anonymous)";
4402    }
4403
4404    function ioBase()
4405    {
4406        if(oEMU.system && oEMU.system.IORANGES && oEMU.system.IORANGES.HostIO)
4407            return oCOM.parseRngExpr(oEMU.system.IORANGES.HostIO).from;
4408        return 0xC000;
4409    }
4410
4411    function reportRange(range)
4412    {
4413        if(!range) return "";
4414        return fmtRange(range.from,range.to,0)+" ("+fmtRange(range.from,range.to,ioBase())+")";
4415    }
4416
4417    function fmtRange(from,to,base)
4418    {
4419        return "$"+(base+from).toString(16).toUpperCase()+"-$"+(base+to).toString(16).toUpperCase();
4420    }
4421
4422    function ioSpaceLabel(from,to)
4423    {
4424        var hostIO = parseNamedRange("HostIO");
4425        var hostROM = parseNamedRange("HostROM");
4426
4427        if(within(from,to,hostIO)) return "HostIO";
4428        if(within(from,to,hostROM)) return "HostROM";
4429
4430        for(var n=0;n<8;n++)
4431        {
4432            var slotIO = parseNamedRange("SlotIO",n);
4433            var slotROM = parseNamedRange("SlotROM",n);
4434            if(within(from,to,slotIO)) return "SlotIO PR#"+n;
4435            if(within(from,to,slotROM)) return "SlotROM PR#"+n;
4436        }
4437        return "";
4438    }
4439
4440    function parseNamedRange(name,n)
4441    {
4442        if(!oEMU.system || !oEMU.system.IORANGES || !oEMU.system.IORANGES[name]) return null;
4443        return oCOM.parseRngExpr(oEMU.system.IORANGES[name],{"n":n || 0,"base":ioBase()});
4444    }
4445
4446    function within(from,to,range)
4447    {
4448        return range && from >= range.from && to <= range.to;
4449    }
4450
4451}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.