FujiNet Go ColecoVision (desktop) -- what is done and what is next
==================================================================

The port follows fujinet-go-adam-desktop/PORTING.md, whose milestone order
(section 12) this file adapts. The derivation is unusual in the family: there
is no fujinet-go-coleco Android sibling to take the target-specific half from,
so instead

    coleco-desktop = adam-desktop (shape, adamcore, Z80/VDP debugger)
                   + astrocade-desktop (mailbox cart, staged protocol,
                                        ROM-less compliance)
                   + intv-desktop (the keypad window, bindings.c)

ColecoVision is the only cart-slot platform in fujinet-firmware/pico with a
finished, corpus-verified mailbox protocol (M7 soak: 192/192 cartridges pushed,
swapped and byte-compared) and no app to run it -- until now it was reachable
only by grafting a device into a MAME tree by hand.

Ports, fixed for this target (never 9995, which pico/coleco's MAME dev harness
and a standalone fujinet-pc-rs232 already use):
  BoIP (FujiBus over SLIP/TCP; FujiNet listens, the cart dials in):  11502
  Web UI:                                                            11503

Two structural decisions, both settled before any code was written:
  * The ColecoVision-side emulator work (the adamcore_cart_ops vtable, the
    Opcode Super Game Module, the corrected stock-console memory map) lands
    UPSTREAM in adamcore, not as a patch script here. Same maintainer, and the
    SGM benefits fujinet-go-adam-desktop too.
  * colmap.c stays OUT of adamcore. It derives its mapper behaviour from MAME
    and says so in its own comments; adamcore is strictly clean-room. It is
    staged into this repo alongside fujimail/fujibus/fujitcp instead, so the
    emulated cartridge and the real RP2040 one are identical by construction.


Done (this pass) -- M0
----------------------

  [x] Root CMakeLists: version single-source (project VERSION 0.1.0),
      FRONTEND switch with the found-or-skipped add_frontend(), GNUInstallDirs
      at the TOP level (PORTING.md 10.1), CPack per frontend, the icon install
      loop, and the no_embedded_roms ctest wiring for WITH_COLECO_ROMS=OFF
      (the default here -- unlike the ADAM app this repo does not redistribute
      a BIOS).
  [x] .gitignore with !build-aux/ negated (PORTING.md 10.2) and both staged
      trees ignored.
  [x] cmake/Dependencies.cmake -- coleco_provide_dependency(), transposed from
      astrocade's. adamcore pinned 2fd4ff058; fujinet-firmware pinned
      844eb6f24 on branch colecovision-bringup (pico/coleco is NOT on master).
  [x] cmake/StageAdamcore.cmake -- stages include/ + src/ into
      core/adamcore-generated. Keys the .source-info marker on the source
      commit AND a hash of the staging file itself, so editing the stage
      re-stages. Detects whether the pin carries adamcore_cart_ops yet and
      says so plainly instead of letting core/ fail on an unknown type.
  [x] cmake/StageFujiProto.cmake -- stages the ten protocol files
      pico/coleco/emu/apply.sh grafts into MAME, unpatched, into
      core/coleco/fuji-generated. Errors at configure time naming the
      colecovision-bringup branch if the pin has no fuji_mailbox.h.
      Deliberately does NOT reproduce apply.sh's fujins.h symbol prefixing:
      that exists because two FujiNet carts in one MAME binary collide on
      fujimail_paint(), and only one is ever linked here. Comment says so.
  [x] core/CMakeLists.txt -- coleco_core static lib from both staged trees.
      The cart device is conditional on adamcore_cart_ops so the tree builds
      today (a plain ColecoVision, no FujiNet) rather than failing configure.
      The hand-listed adamcore source list carries a note: it is the same trap
      fujinet-go-adam-desktop has, and goes away when adamcore ships an
      adamcore-sources.cmake (M1 step 7).
  [x] tools/icons/make-icons.py + rendered hicolor set, .icns and app.ico.
      Background #8C9720 -- the complement of the ADAM app's #8D84E5: hue
      65.6 deg (exactly 180 opposite), same saturation, and HSL lightness
      chosen so RELATIVE LUMINANCE matches ADAM's 0.278. That last step is the
      point: all six siblings composite byte-identical foreground artwork, and
      the literal complement #DCE584 is 2.6x more luminous, dropping contrast
      from 3.20:1 to 1.35:1 and washing the wordmark out.

  VERIFIED on this machine:
    cmake -B build -DFRONTEND=none -DADAMCORE_SRC=~/Workspace/adamcore \
                                   -DFUJINET_SRC=~/Workspace/fujinet-firmware
    cmake --build build && ctest --test-dir build
  -> configures, stages both trees, builds, 1/1 test passes. The test
     (core/tests/no_sdl_link_test.c) is not a stub: it runs the staged
     fujibus_selftest() (the firmware's own known-good wire vectors), plans a
     flat 32K image through the staged colmap, and creates + runs a frame of +
     destroys a ColecoVision. `nm -u` shows zero SDL symbols in
     libcoleco_core.a and ldd on the test binary shows only libm/libc.


Done (this pass) -- M1: the adamcore work (upstream)
----------------------------------------------------

Six commits on adamcore's colecovision-cart-and-sgm branch (a70711f..f9f3820),
in an order chosen so a regression bisects cleanly. harte and zexdoc bind
their own mem_read and were untouched throughout, so they stayed the
regression gate on z80.c -- which none of this modified.

  [x] 1. commit split: mem_read_at(c, a, commit); mem_read -> 1,
         machine_mem_read -> 0. Same for writes. No behaviour change.
  [x] 2. adamcore_cart_ops vtable + adamcore_cart_insert.
         CORRECTION to the plan, found by writing the test: the debugger's
         peek DOES reach the device, and has to -- otherwise adamcore_peek
         over a device-served window would report the stale cart[] array
         instead of what is really there, making a memory view fiction. The
         contract is that it arrives with commit=0. MAME does the same.
         adamcore_poke stops short of ops->write, so a memory edit cannot
         switch a bank. ops->reset is power-on only.
  [x] 3. cart_fill extracted from cart_load (folded into commit 2; both are
         additive with no behaviour change, so nothing is lost for bisect).
  [x] 4. CV memory accuracy, landed alone as planned.
  [x] 5. PSG refactor: psg.c owns the queue/timeline/resync/mix, sn76489.c is
         a pure chip, ay8910.c is new and inert (have_ay is always 0 here).
  [x] 6. SGM ports $50-$53 and the $7F switch.
  [x] 7. PROVENANCE.md, README, adamcore-sources.cmake, cli -sgm.

  VERIFIED, and not on "it compiles":

  * cart_ops_test (new, needs no ROMs, never skips): exactly one committed
    read per Z80 bus access across LD A,(nn), LD A,(HL), LD r,(IX+d), LDIR,
    CPIR, OUTI, POP, an M1 fetch from cart space, and the read-modify-write
    cases -- INC (HL) and RES b,(IY+d) at 1r+1w, EX (SP),HL at 2r+2w, which
    is what silicon does too. peek/peek_block over the whole 32K window
    produce only commit=0 reads and no writes; poke produces nothing;
    ops->reset fires once at install and survives both console reset modes.
    The z80.c audit that predicted this held: every memory read in the
    interpreter funnels through one rd() at src/z80.c:53.

  * sgm_test (new): the stock console's 1K at $6000-$7FFF mirrored four
    times, open bus below, and $73B9 aliasing onto $63B9 -- the aliasing the
    FujiNet cartridge firmware is built around. Then the ports: $53 opening
    and closing the 24K, $7F swapping RAM over the BIOS, and a sweep of ALL
    256 values of $7F asserting the cartridge window survives every one
    (letting $7F reach mem_ctrl would have unmapped the cartridge for any
    value whose bits 2-3 are not both set). AY register round trip and
    masking through $50/$51/$52. ADAM in game mode explicitly unchanged.

  * psg_test (new): GOLDEN_SUM, a checksum of the rendered output for a fixed
    program driving the PSG through real OUT instructions, captured BEFORE
    the refactor. It still matches sample for sample, which is the proof the
    extraction was exact. It also caught a real bug: AY fixed volume 0 must
    be true silence, not envelope level 1, or every SGM-fitted machine
    carries a constant DC offset. And it confirms the headroom analysis --
    three AY channels on top of four SN channels reach the rail and produce
    56 saturated samples with no sign wrap.

  * Real hardware ROMs, not just unit tests: the OS-7 BIOS boot screen and a
    running cartridge render PIXEL-IDENTICALLY before and after the whole
    series (adamcore-cli --ppm, 300 frames, cmp). Real software stays inside
    the real 1K by construction, so correcting the map is invisible to it.

  * From-scratch configure + build + 7/7 ctest on adamcore; the app tree
    rebuilds against the new pin and still passes its own test.

  SETTLED, by the right instrument. super_game_module_test.rom (Oscar Toledo
  G., 2013) is a ColecoVision program that PROBES the SGM and reports what it
  finds -- so running it is learning about the machine from a diagnostic, not
  about an emulator, and it needs no clean-room caveat at all.

  This core reaches TEST COMPLETED!, through every stage:
      DETECTED: COLECOVISION      <- machine identification
      BASE 1K RAM OK!             <- the stock 1K and its mirror
      SUPER GAME MODULE DETECTED  <- detection through port $53
      BASE 24K RAM OK!!           <- the 24K at $2000-$7FFF
      LOWER 8K RAM OK!            <- the $7F bit-1 BIOS/RAM swap
      TESTING SGM SOUND CHIP
      PLAYING + BASE SOUND CHIP...
      LET US ENJOY SGM MUSIC!     <- AY audio: peak 19704, 88% non-silent
      TEST COMPLETED!
  With cfg.sgm clear it correctly stops at "NO SUPER GAME MODULE DETECTED"
  and the audio is pure silence, so the stock console is genuinely bare.

  That answers the questions that mattered: what owns $6000-$63FF alongside
  the 24K (the "BASE 1K RAM OK!" and "BASE 24K RAM OK!!" stages pass
  together), what $53 gates, and what $7F bit 1 does. CoolCV was run on the
  same ROM as a black-box A/B and shows the identical sequence -- its binary
  was NOT disassembled, which would have derived behaviour from another
  emulator and broken the posture PROVENANCE.md states.

  STILL OPEN, but no longer blocking -- nothing observed depends on them:
    * whether $50-$53 mirror across $40-$5F (currently open bus outside
      $50-$53, the conservative answer, and no software probes it)
    * whether $7F is readable on an SGM (currently reads $FF)
    * the exact AY volume law (1.5 dB per envelope step assumed). The music
      plays correctly; only its precise loudness curve is unconfirmed.

  MERGED AND PINNED: tschak909/adamcore#1 landed on main (fccc280ef) and
  cmake/Dependencies.cmake pins it. -DADAMCORE_SRC is no longer needed for a
  normal build; it remains the tandem-development override.

  CLONE-AND-BUILD VERIFIED, which is the promise PORTING.md 1.3 makes and the
  one that could not hold until the merge: a fresh `git clone` with no
  third_party, no submodule init and no SRC overrides fetched BOTH
  dependencies at their pins, built the core, both frontends and every test,
  and passed 15/15 -- with the five ROM-dependent tests skipping, since a
  clone has no BIOS, which is exactly what CI and a new contributor see.


Done (this pass) -- M2: the mailbox cartridge device
---------------------------------------------------

  [x] core/coleco/fujinet_cart.{c,h} -- a C transposition of the firmware's
      own MAME device (pico/coleco/emu/fujinet.cpp), the same relationship
      astrocade-desktop's core/astro/fujinet_cart.c has to its MAME device.
      Supplies only the fujimail_port_t callbacks; the protocol, the wire
      codec and every cartridge mapper are the staged firmware sources,
      compiled verbatim. std::vector becomes a small bytebuf; MAME's
      side_effects_disabled() becomes adamcore_cart_ops' commit flag; the
      image comes from the app rather than a MAME cart slot, and with no
      image the baked-in CONFIG client is served exactly as the RP2040 does.
  [x] The staged tree is wired into the build and links: fujimail, fujibus,
      colmap and fujitcp all present in libcoleco_core.a alongside the device.

  VERIFIED:

  * core/tests/cart_test.c (new) drives the device through adamcore's bus
    with a real Z80 instruction stream -- NOT by calling
    fujimail_read_hotspot() directly, which is what the firmware's own
    host_test does and what MAME's device does from the other side. This is
    the layer neither covers, and the distinction is the point: every mailbox
    touch is a cartridge READ whose ADDRESS is the payload, so a bus that
    dispatched a read twice, or fired one for a debugger peek, or dropped the
    commit flag would pass upstream's tests and fail here.
    Passing on the first run: the CONFIG client served with 'F' 'N' magic,
    proto version and the "FUJI" claim in place; a GET_ADAPTERCONFIG_EXTENDED
    transaction launched entirely from LD A,(nn) instructions with ACKSEQ
    echoing the sequence number; the link-down path reporting FN_ERR_NOLINK;
    a debugger sweep of all three hotspot pages ($FD00-$FFFF, which through
    the bus would arm registers, append TX bytes and attempt the ROM swap at
    $FDFE) leaving ACKSEQ and the error byte untouched; an unpaired REGDATA
    launching nothing; a repeated SEQ not relaunching but a fresh one doing
    so; and an unsigned image running as a plain cartridge with the mailbox
    dead and its bytes served verbatim.

  * The firmware's own protocol suite still passes against the same sources
    this repo stages (make -C pico/coleco/firmware/host_test): test_fujibus,
    test_colmap, test_fujimail, test_busedge all green.

  NOT YET DONE in M2: a live round trip against a real fujinet-pc. That is
  M4, where the in-process runtime lands; cart_test deliberately points at a
  dead port so it stays hermetic.

Done (this pass) -- M3: the session layer and a running app
-----------------------------------------------------------

  [x] core/coleco/host.{c,h} -- the paced emulator thread and the frame slot.
      Simpler than the siblings in one respect: adamcore's audio is a
      documented audio-thread-safe pull from its own timestamped queue, so
      the device pulls straight through and there is no ring to keep in step.
  [x] core/include/colecosession.h -- the frontend contract.
  [x] core/src/{session,settings,paths,roms,audio_sdl,fujinet_runtime}.c --
      settings/paths/audio/fujinet_runtime transposed from astrocade, with
      the leftover intv_ prefixes THOSE files still carry from their own
      transposition normalised out rather than inherited. roms.c written
      fresh: one BIOS that is deliberately absent is a different problem from
      Astrocade's three variants, and the empty case is the interesting one,
      so the error names the directory and points at the import.
  [x] core/src/input_map.c -- a pure function over an explicit held-key set,
      no session and no toolkit, because that is the only shape that can be
      unit-tested and it is where the sibling ports actually caught their
      input bugs.
  [x] frontends/gnome -- GTK4/libadwaita: display on the compositor's frame
      clock, header-bar menu, keyboard capture with focus-loss release, BIOS
      import, live FujiNet status, TV/square aspect and smooth-scaling
      toggles persisted to the shared settings store.

  VERIFIED:

  * RAN ON LIVE WAYLAND (grim): the app boots the baked-in FujiNet CONFIG
    client through the mailbox cartridge and paints "FUJINET CONFIG / SELECT
    A HOST" in a native window, with the header bar correctly reporting
    "FujiNet: link down" because no runtime is up yet. That is the whole
    stack -- adamcore, the staged protocol sources, the transposed cart
    device, the host thread, the session and the frontend -- working
    together.
  * boot_smoke caught a real pacing bug: waiting on a vsync tick that never
    arrives cost the timeout every frame and pinned a frontend with no frame
    clock at a steady 30 fps. Asking whether ticks are arriving BEFORE
    blocking is what makes the wall-clock path a fallback rather than a
    penalty. 60 fps now; the lower bound is 50, not something loose enough to
    have called 30 a pass. It also exercises the phase-lock path.
  * input_test pins exact controller words, including the hold semantics that
    matter: a keypad key is HELD, not pulsed (the machine samples once a
    frame, so an instant's press falls between frames), and rolling from one
    digit to another never leaves the machine with no key held.
  * session_test runs against a real temporary XDG tree: paths, settings
    round-tripping AND persisting across sessions, the SGM defaulting on, the
    BIOS gate failing with a useful message rather than a black screen, then
    importing a real BIOS and running to a delivered frame.
  * 9/9 ctest, including no_embedded_roms against the actual GNOME binary
    (with a real OS7.rom in tools/roms, so the check is meaningful),
    desktop-file-validate and appstreamcli --pedantic.


Done (this pass) -- M4: FujiNet end-to-end, in-process
------------------------------------------------------

  [x] tools/fujinet/build-fujinet-desktop.sh + support/fujinet_desktop_entry.cpp
      + patches/0001-rs232-default-hosts.patch, transposed from astrocade
      (already PC_TARGET=RS232); ports 11500/11501 -> 11502/11503.
  [x] cmake/FujiNetRuntime.cmake wired into the top-level build, so a plain
      `cmake --build` produces the runtime with no separate step.

  TWO REAL BUGS INHERITED FROM THE SIBLING, fixed rather than carried:
    * fujinet_desktop_entry.cpp's default web-admin bind was hardcoded to
      127.0.0.1:64003 -- the INTELLIVISION port. It is normally masked
      because fujinet_runtime.c always sets FUJINET_WEBUI_BIND, but the
      moment it is not, the app binds another app's port and collides with
      it rather than failing visibly. astrocade still carries this.
    * The FujiNet build key file was named .intv-target. Cosmetic, but it is
      the same drift, and the jzIntv comments describing a core this target
      does not use went with it.

  VERIFIED, running:

  * The app LISTENS on 127.0.0.1:11502 (BoIP) and :11503 (web UI); the web UI
    answers HTTP 200. Checked with ss and curl, and checked FIRST that
    nothing else held those ports (PORTING.md 10.20 -- there are in fact two
    stray fujinet-pc processes on this machine, on 8000 and 8003, which is
    exactly the situation that catches people out).
  * The cartridge dials into its own in-process FujiNet: both ends of an
    ESTABLISHED loopback connection belong to the same PID. The log shows
    "Setting up BoIPChannel: listening on 127.0.0.1:11502", then
    "BoIPChannel connected", then our own "fujinet: connected to
    127.0.0.1:11502". (The connect/disconnect pair just before it is
    fujinet_wait_for_boip probing the port, by design, not a double connect.)
  * A REAL TRANSACTION ON THE WIRE, end to end:
        CF: dev:70 cmd:f4 dlen:-1
        Fuji cmd: READ HOST SLOTS
        fujinet: dev=70 cmd=F4 nparam=0 txlen=0 seq=1
                 -> err=0 reply=06 rxlen=256 "SD"
    device 0x70, command 0xF4 (READ_HOST_SLOTS), seq=1 (the client's first,
    derived from ACKSEQ+1 as the protocol requires), ACK, 256 bytes of host
    slots back. That is the Z80's hotspot reads -> fujimail -> fujibus ->
    SLIP -> TCP -> libfujinet's own Fuji handler and back into the cartridge
    window.
  * ON SCREEN (grim, live Wayland): the header bar reads "FujiNet connected"
    and the CONFIG client lists its hosts -- "1 SD" under the selection bar,
    "2 IRATA.ONLINE", 3-8 empty, with the "FIRE OPEN  9 RENAME  # CONFIG"
    footer. The selection bar is the client's second-charset-copy trick
    working through this emulator's VDP.
  * Reconfiguring does NOT trigger a multi-minute FujiNet rebuild
    (PORTING.md 10.5's configure_file COPYONLY stamp): an incremental build
    after a fresh configure takes 0.13s.
  * mbedTLS on this machine is 4.2.0, which 10.16 says is too new (4.x drops
    mbedtls/md5.h); the script detected that and built the pinned 3.6.5.

  NOT YET EXERCISED: a network boot (DBC push -> FN_BOOT_READY -> BOOTLOCK ->
  the $FDFE swap). The path is corpus-verified upstream (M7 soak, 192/192)
  and the device is a transposition of the model that was soaked, but it has
  not been driven from this app against a live TNFS host yet.


Done (this pass) -- M5 (part): the controller keypad window and bindings
-----------------------------------------------------------------------

  [x] core/src/bindings.c -- a flat target table (both controllers' 12 keypad
      keys, 4 directions and 2 fire buttons, then the machine-wide system
      actions), each holding at most one keysym, persisted to the shared
      settings store. Written FRESH rather than transposed: astrocade's model
      is one 24-key console keypad plus a knob, which does not describe two
      ColecoVision hand controllers at all.
      Rebinding STEALS -- a key drives exactly one control, because one
      keystroke doing two things is worse than losing the old binding: the
      second effect is invisible until it matters.
      Seeds its defaults LAZILY, so the table is usable with no session
      behind it. A frontend that forgot to load bindings, or a unit test with
      no settings store, gets the documented default map rather than silently
      no input at all.
      Keysyms are folded before lookup: case (so a binding made with 'w'
      still fires while Shift is held for something else) and the numeric
      keypad onto the top row (one binding drives both -- a user who binds
      "5" means the digit, and a machine whose entire input model is a 12-key
      keypad having a dead numpad would be perverse).
  [x] input_map.c reworked to resolve through the table, and MOVED from
      coleco_core to coleco_session: it now needs the settings-backed
      bindings, and dragging the settings store down into the SDL-free core
      to avoid that would have been the wrong trade.
  [x] colecosession_press / _sysaction / _reset_to_config.
      reset_to_config is a real feature, not a convenience: after a network
      boot the cartridge is serving the game, and a plain reset cannot undo
      that because the cartridge never sees the console's reset line. Only
      rebuilding the cartridge puts CONFIG back -- on hardware that is a
      power cycle.
  [x] frontends/gnome/keypad/keypad_window.c -- both controllers side by
      side, 3x4 keypad + four-way stick + two fire buttons each, a System row
      (Reset Console / Reset to CONFIG) and a Map row (Map / Defaults).
      F9 toggles it; singleton, hidden-not-destroyed so a remap survives
      closing it; NOT visible at startup (PORTING.md 1.8).
      Every control uses a raw GtkGestureClick press AND release, never
      GtkButton's "clicked" -- the emulator samples the controller once per
      frame, so a value present only for the instant of a click falls between
      frames and a polling game (which is how essentially every title reads
      its skill select) never sees it. "cancel" is wired to the same release
      path, so dragging off a button cannot strand a key held forever.
      COLECO_OPEN_KEYPAD=1 opens it at launch, following the family's
      <T>_OPEN_DEBUGGER convention.

  VERIFIED:

  * RAN AND PHOTOGRAPHED on live Wayland: the panel shows Controller 1 and
    Controller 2 side by side with the 1-9 / * 0 # pad, the direction cross,
    Fire L/R, the System row and the Map row -- the layout that was agreed
    before any of it was written.
  * bindings_test (new), passing first run: labels; the defaults actually
    driving the machine; rebinding stealing the key from whatever held it,
    and BOTH halves of the theft persisting across a session restart;
    Defaults restoring; and the property that matters most for the panel --
    that it presses the CONTROL, not a key, so an on-screen button keeps
    working after the key it looks like has been remapped elsewhere. Plus
    that system actions are not controller state.

  [x] media.c + Open/Eject/drag-and-drop. media_test caught a bad error
      message before a user could: a disk image with no FujiNet runtime
      failed with "could not copy", which says nothing about the real
      problem, because the path is always computed and only the DIRECTORY is
      missing.
  [x] gamepad_sdl.c -- a polling thread (SDL_PollEvent must run on the thread
      that initialised video, and this app's video belongs to GTK, not SDL),
      pads assigned to ports in connection order, hotplug by rescan, and a
      pad that vanishes releases its port rather than leaving a direction
      held forever. Pushes only on change, so a pad re-asserting idle cannot
      stamp on a key being held.
      The keypad is deliberately NOT mapped to face buttons: twelve keys do
      not fit on a gamepad, and guessing a subset would make some games work
      and others fail for reasons the user cannot see. The panel covers it.

  VERIFIED AGAINST REAL HARDWARE (a USB 2-axis 8-button pad, which SDL
  identifies as an iBuffalo SNES Controller). 547 state changes over one
  session, and every controller word the pad produced matches the value
  input_test pins for that control exactly:

      UP 7E7F   DOWN 7B7F   LEFT 777F   RIGHT 7D7F
      FIRE-L 3F7F   FIRE-R 7F3F

  Three things that only real hardware could establish:
    * All four DIAGONALS work (UP+LEFT 767F, UP+RIGHT 7C7F, DOWN+LEFT 737F,
      DOWN+RIGHT 797F). This pad reports its d-pad as two AXES, not as hat
      buttons, so the diagonals prove the STICK_THRESHOLD path -- which no
      unit test could reach, and which differs per controller.
    * Fire and direction TOGETHER (397F = DOWN+RIGHT+FIRE-L, 373F =
      LEFT+FIRE-L+FIRE-R). That is the case that breaks if only the changed
      port is pushed, or if the two fire bits are masked wrongly -- they live
      in different bytes of the word.
    * The pad is opened and assigned to port 1 by connection order with no
      configuration.

  STILL OPEN in M5: binding gamepad buttons as well as keys (the binding
  table has room for it; only keys are wired).


Done (this pass) -- M6: the debugger
------------------------------------

  [x] core/debugger/{debugger,z80dasm,symbols,symbols_builtin,vdpview}.c
      transposed from adam-desktop -- same Z80, same TMS9928A, so the
      disassembler and the video view carry over directly. z80dasm.c is a
      fresh implementation from the Zilog manual (ADAMEm's is
      non-commercial-licensed and GPL-incompatible; COMPLIANCE.md).
      symbols_builtin.c keeps the OS-7 table and DROPS the EOS one -- the
      ColecoVision has no EOS.
  [x] RE-ARCHITECTED on the way across, not just renamed. The ADAM engine
      reaches into its session struct for the core (s->core, s->dbg_engaged,
      s->audio_mute, s->stop_flag), which is why its debugger cannot exist
      without a session. Here the core is host.c's, so the engine is a
      singleton beside it and host.c grew three seams:
      set_debug_engaged (run the stepped path), set_audio_mute (silence while
      paused, or the last few ms before the break repeat forever) and
      stopping (so a debugger blocked in its pause wait lets go at shutdown),
      plus a frame function the paced loop calls while engaged.
      Engagement is conditional: adamcore_debug_run is bit-identical to
      run_frame (adamcore's own debug_step test pins that) but costs more, so
      an open-but-idle debugger window must not slow the machine.
  [x] frontends/gnome/debugger/debugger_window.c -- Z80 tab (registers
      including the shadow bank, disassembly with breakpoint and PC markers,
      hex memory view, breakpoint entry, Follow PC) and VDP tab (all eight
      registers decoded, status, address latch). F12 opens; F5 pause/
      continue, F7 step in, F8 step over, Shift+F8 step out. Refreshes only
      while VISIBLE -- reading the machine costs real work and doing it
      behind a hidden window would slow the emulator for nobody. Closing the
      window RESUMES: leaving it paused behind a closed debugger looks
      exactly like a hung emulator.
      COLECO_OPEN_DEBUGGER=1 opens it at launch.

  VERIFIED:

  * debug_test (new): pause takes hold and the machine really stops (PC and
    cycle count unchanged across 80 ms); a step advances it and it is paused
    again; memory reads repeatably (a debugger read must not perturb
    anything -- the property adamcore's commit flag exists for); the BIOS
    disassembles; the built-in OS7 symbol table loads; breakpoints set, list
    and clear; and after resume the machine is genuinely running again.
  * The disassembler is right about real code, not just self-consistent: it
    renders the BIOS reset vector as "LD SP,$73B9 / JP $006E", and $73B9 is
    the documented ColecoVision stack top -- the same value the cartridge
    firmware's swap-stub notes rely on.
  * RAN AND PHOTOGRAPHED: the window shows live registers (PC in cart space,
    the CONFIG client running), a following disassembly, and a memory view at
    $0000 reading 31 B9 73 C3 6E 00 -- the same reset vector, in bytes.
    Meanwhile the main window keeps running the CONFIG client at full speed,
    which is the conditional-engagement claim holding.

  CORRECTED LATER IN THIS PASS. What is written above understates how badly
  the first attempt fell short, and the hand-written GNOME window it describes
  has been replaced.

  The engine's vdpview.c exports TWELVE functions -- render_nametable,
  render_patterns, render_sprites, render_palette, get_tables, format_state,
  format_hex, parse_poke, describe_register, describe_status, register_name,
  color_name -- and the window used TWO of them. There was also no debugger at
  all in the KDE, macOS or Windows frontends.

  Fixed by transposing adam-desktop's four complete debugger windows instead
  of hand-writing them: same Z80, same TMS9928A, same vdpview.c, and a clean
  transposition (nothing in them touches ADAM-specific machine state -- no
  mem_ctrl, no AdamNet, no SmartWriter). All four now have CPU / VDP / Trace
  tabs, breakpoints addressable BY SYMBOL, a trace ring, a memory pane and
  the full VDP decode with every renderer wired up.

    frontends/gnome/debugger/dbg_window.c        847 lines
    frontends/kde/debugger/DebuggerWindow.cpp    645
    frontends/macos/debugger/DebuggerWindow.m    769
    frontends/windows/debugger/dbg_window.c     1059

  VERIFIED BY RUNNING THEM:
  * GNOME: CPU/VDP/Trace tabs, full Z80 register file with flags and cycle
    count, breakpoints-by-symbol, memory dump.
  * KDE: the same, and its VDP pane LIVE-RENDERS the name table -- "FUJINET
    CONFIG / SELECT A HOST" drawn from VRAM -- with the pattern table beside
    it and Nametable/Patterns/Sprites and VDP/VRAM/Trace tabs.
  * Windows: runs under Wine with the debugger window up (disassembly,
    registers, breakpoints, memory). Needed comctl32 linked, which the link
    error found; the v6 manifest was already there to theme it.
  * macOS: still cannot be compiled here. Every debugger and session symbol
    it calls resolves against the public headers.

  Two things the frontends needed on top of the transposition: F12 and a
  View-menu item in all four, and COLECO_OPEN_DEBUGGER / COLECO_OPEN_KEYPAD
  launch hooks (the Windows frontend had neither).


Done (this pass) -- M7: the KDE frontend
----------------------------------------

  [x] frontends/kde -- plain Qt6 Widgets, deliberately NOT KDE Frameworks: it
      picks up Breeze through the platform theme anyway, and staying
      framework-free keeps the frontend usable outside a KDE session.
      MainWindow (menu bar, drag-and-drop, media import, BIOS import, status
      bar), DisplayWidget, KeypadWindow (the Qt twin of the GNOME panel, same
      layout, same held-not-pulsed semantics via a PadButton that overrides
      mousePress/Release rather than using "clicked"), KeyForward.
  [x] KeyForward.cpp is the only place a keysym translation exists in the
      whole project. The session's table is written in X11/xkb keysyms
      because a GDK keyval already IS one, so GNOME needs no translation;
      Qt is the frontend that maps, and doing it here rather than teaching
      the table a second vocabulary is what lets ONE tested table serve
      every frontend.
  [x] enable_language(CXX) must come BEFORE find_package(Qt6), not after:
      Qt's config runs check_cxx_source_compiles (WrapAtomic among others),
      which fails outright with no C++ compiler enabled. Cost a configure
      failure to discover; the comment says so.

  VERIFIED: RAN AND PHOTOGRAPHED on live Wayland. The Qt window boots the
  CONFIG client through the same core, lists the same host slots, and its
  status bar reads "FujiNet connected". The Qt controller panel opens with
  the same two-controller layout as the GNOME one.

  This is the milestone that proves the core is genuinely toolkit-agnostic:
  two frontends, two toolkits, one session contract, no #ifdefs.


Done (this pass) -- M8 (part): CI, and the Windows build made real
------------------------------------------------------------------

  [x] core/coleco/fujitcp_win32.c -- the Winsock twin of the staged POSIX
      fujitcp.c, compiled INSTEAD of it on Windows against the same staged
      header, so the staged tree stays a verbatim copy of the firmware's own
      and `git diff` against fujinet-firmware stays empty. It was referenced
      by core/CMakeLists.txt from M0 and did not exist; any Windows build
      would have failed at link.
      A line-for-line transposition, not a rewrite: the behaviour that
      matters was paid for elsewhere -- walking every getaddrinfo result
      (localhost resolves to ::1 first, fujinet-pc binds 127.0.0.1 only),
      RX_RAW_MAX at 1088 for a SLIP-encoded 512-byte DBC push, and a push
      frame consumed mid-transaction RESTARTING the deadline rather than
      counting it down. Winsock's differences are mechanical: SOCKET is
      unsigned, no MSG_DONTWAIT (a zero-timeout select before each byte does
      the same job), no MSG_NOSIGNAL (Windows never raises SIGPIPE),
      WSAStartup first.
  [x] cmake/toolchains/mingw-w64.cmake -- the desk-side Windows check.
  [x] coleco_session is now found-or-skipped on SDL3, like the frontends.
      coleco_core is SDL-free by construction, so -DFRONTEND=none must still
      configure without SDL; making it REQUIRED aborted the whole configure
      for a dependency only the session layer has, which is exactly what the
      cross build hit.
  [x] .github/workflows/{linux,macos,windows}.yml.

  VERIFIED, and better than "it configures":

  * The ENTIRE core cross-compiles for Windows with -Wall -Wextra clean, and
    its tests RUN UNDER WINE: cart_test (the whole mailbox protocol, all
    stray-read defences included), no_sdl_link_test, and boot_smoke at 54 fps
    in 1006 ms. That last one is the point -- clock_nanosleep(TIMER_ABSTIME)
    is a no-op under mingw/Wine, and without host.c's relative-nanosleep
    fallback the emulator would have free-run at thousands of fps. The trap
    is caught at the desk rather than on a push.
  * The linux CI configuration reproduced locally, with the BIOS moved out of
    the tree so the checkout looks like CI's: 15/15 pass and the five
    ROM-dependent tests SKIP (ctest 77) rather than fail.

  CORRECTION: the linux/macos/windows workflows this pass claimed to add were
  written while the shell's working directory was inside build-win/, so they
  landed under a build tree that was deleted minutes later and were never in
  the commit. Rewritten from the repository root and committed with the
  flatpak work below. The commit message that claimed them was wrong.

  [x] release.yml -- see M9.


Done (this pass) -- M9 (part): the Windows frontend
---------------------------------------------------

  [x] frontends/windows -- Win32 + GDI, no toolkit. Menu bar, StretchDIBits
      display with the same 4:3/square and smooth/nearest choices as the
      other two, file dialogs, drag-and-drop, and the controller panel.
  [x] DwmFlush() on a present thread is this platform's frame clock. There is
      no GdkFrameClock here and a plain timer would beat against the panel;
      the thread does nothing but wait for the compositor and hand the tick
      to the session. If the DWM is off it falls through immediately and the
      wall-clock pacing takes over, which is why notify_vsync is advisory.
  [x] WM_ACTIVATE releases every held key. Windows eats the key-up when focus
      leaves, so without it the machine believes whatever was held is still
      down -- alt-tab away mid-jump, come back to a character walking into a
      wall.
  [x] The panel captures the mouse on press and releases on button-up
      wherever that lands, so dragging off a button cannot strand a key held.
  [x] key_forward.h is the Win32 half of the same one-table rule the Qt
      frontend follows: map into the session's keysym table rather than grow
      a second vocabulary.
  [x] The manifest is generated from PROJECT_VERSION (comctl32 v6 for themed
      controls, per-monitor DPI so a nearest-neighbour blit is not resampled
      twice).

  VERIFIED, on an actual Windows binary:

  * The whole thing cross-compiles with mingw-w64, warning clean, after
    cross-building SDL3 for the target.
  * SELF-CONTAINED, checked the way PORTING.md 8.2 insists -- objdump -p on
    the import table, not ldd, which resolves against the build machine's
    PATH and lies. Only Windows system DLLs: no libwinpthread, no SDL, no
    libgcc.
  * IT RUNS UNDER WINE: a native Win32 window with the menu bar, booting the
    CONFIG client. "fujinet: cannot connect to 127.0.0.1:11502 (WSA 10061)"
    in the log is fujitcp_win32.c's own error path, so the Winsock transport
    is live rather than merely compiled.
  * ALL 8 TESTS PASS under Wine on the Windows build -- including
    session_test, media_test and debug_test, which exercise the path layer,
    file I/O and the debugger there. Four test files needed portability
    shims (_mkdir/_access/_getpid) to get that far; they are worth having,
    because those tests are the only thing that will run on the Windows CI
    runner.

  [x] frontends/macos -- AppKit. Window, menu bar, display, controller
      panel, media import, drag-and-drop, BIOS import.
      CVDisplayLink is this platform's frame clock (the equivalent of
      GdkFrameClock and DwmFlush). Its callback runs on its OWN thread, so it
      does the two cheap things -- hand over the tick, pull the frame -- and
      then dispatches to the main queue to redraw. Drawing from the callback
      thread would be AppKit off the main thread, which is undefined.
      flagsChanged is handled, not just keyDown/keyUp: modifier keys arrive
      only through it, and BOTH fire buttons are modifiers in the default
      bindings, so without it the machine never sees a shot fired from the
      keyboard.
      ColecoKeyForward reads digits and letters from charactersIgnoringModifiers
      rather than the key code, so a French or Dvorak layout binds what the
      user actually typed.
      The .icns is added as a SOURCE with MACOSX_PACKAGE_LOCATION AND named in
      MACOSX_BUNDLE_ICON_FILE -- neither alone is enough, and without both the
      bundle builds cleanly with NO icon, which nothing else in this project
      would catch (PORTING.md 4.4).

  NOT COMPILED: there is no Mac here and no way to build Objective-C against
  Cocoa on this machine. .github/workflows/macos.yml is the first thing that
  will actually try it, and a failure there is real signal about this code.
  What WAS checked mechanically: every session and binding symbol the
  frontend calls is declared in the public headers (so no misremembered name
  or signature), and every file balances.

  [x] build-aux/flatpak/online.fujinet.go.coleco.{gnome,kde}.yml, and
      .github/workflows/flatpak.yml.
      TWO declared dependencies here, unlike the Astrocade port these came
      from, whose emulator core is in-tree: adamcore and fujinet-firmware are
      both pinned to the same commits cmake/Dependencies.cmake uses, so a
      flatpak build cannot pick up a working checkout's state.

  VERIFIED BY ACTUALLY BUILDING BOTH, not just writing YAML:

  * The GNOME flatpak builds, installs and RUNS -- header bar reading
    "FujiNet connected", the CONFIG client listing its host slots, FujiNet
    itself running from the sandbox's own provisioned tree at
    ~/.var/app/online.fujinet.go.coleco.gnome/data/. The KDE one builds too.
  * libfujinet.so and the runtime tree are inside the artifact, so the
    complete FujiNet build works in a sandbox with NO NETWORK -- which is the
    whole point of this job and the strictest test of clone-and-build in the
    project.
  * One real bug found by building it: skipping all of tools/roms to keep a
    developer's BIOS out of the artifact also removed embed-roms.py, which is
    SOURCE the build needs. Now only the image is skipped, with a note
    explaining why the directory cannot be.

  [x] .github/workflows/release.yml -- a v* tag builds Linux packages for
      both frontends, both flatpak bundles, a macOS bundle and a Windows zip,
      and attaches them to a DRAFT release.
      The version check is adapted rather than copied: the sibling ports
      hardcode the version in their metainfo and check that it matches
      project() VERSION, but both of ours are TEMPLATES substituting
      @PROJECT_VERSION@, so they cannot disagree by construction. The check
      is therefore that the template is still a template -- which is what
      makes the guarantee hold -- rather than that two numbers match.
      Its header says plainly what each job proves: linux and flatpak are
      regressions if they go red; windows is the first NATIVE build (the
      cross-build and Wine runs are the desk-side evidence); macos has never
      been compiled anywhere.
  [x] Packaging verified locally, not just configured: cpack -G TGZ produces
      a 3.5 MB package containing both frontends, libfujinet.so, the FujiNet
      runtime tree and both desktop entries -- and NO BIOS. no_embedded_roms
      confirms the shipped GNOME binary contains none of the real OS7.rom
      sitting in tools/roms.
  [x] core/tests/gamepad_probe.c -- an interactive probe, built but
      deliberately NOT a ctest: no CI runner can press a button, and nothing
      automated can tell you whether a given controller reports its d-pad as
      a hat, as buttons or as axes. Exposed
      colecosession_gamepad_last_state() so the layer is observable at all;
      the words it pushes are otherwise invisible, since the machine merges
      them with the keyboard's.


Done (this pass) -- CI green on all four platforms
--------------------------------------------------

The workflows had never actually run until the repository was pushed. They
did what they exist to do.

  Linux     success
  Flatpak   success
  Windows   success  (~13 min: it builds the FujiNet runtime natively under
                      MSYS2, so it is slow rather than stuck)
  macOS     success  -- after fixing THREE real bugs it found

  macOS had never been compiled anywhere, and the first real build proved
  the point of saying so:

    * DisplayView.m used mach_timebase_info with no <mach/mach_time.h>.
      CoreVideo hands out host time in mach_absolute_time units, which are
      not nanoseconds on every machine, so that conversion is load bearing.
    * PadButton declared `int target`, SHADOWING NSControl's own `target`
      property -- the object an action is sent to. Renamed padTarget. The
      compiler warned three ways (atomic mismatch, incompatible type, auto
      synthesis refusing) before erroring, which is a fair warning that a
      control's own machinery was being quietly broken.
    * QuartzCore was not linked, so kCAFilterNearest was undefined at link
      time. The debugger's pattern-table and sprite views need it: an 8x8
      glyph run through bilinear filtering is unreadable.

  None of the three was reachable by the symbol audit that stood in for a
  compiler -- they are about Cocoa's types and Apple's frameworks, not about
  this project's API surface. That is exactly what the macOS job is for, and
  the TODO entry predicting it was right.

  macOS runs 10/10 tests (the ROM-dependent ones skipping, as everywhere).


Done (this pass) -- the first release, and what tagging it caught
----------------------------------------------------------------

  Tagged v0.1.0. check-version, both Linux package sets and both flatpaks
  went green on the first try; macos-package and windows-package failed.
  All three causes share one shape: the push CI and the release build were
  testing different things, so no amount of green before the tag could have
  found them. Worth recording as a family lesson.

  [x] macOS: the bundle had the wrong NAME and the runtime in the wrong
      PLACE. frontends/macos/CMakeLists.txt never set OUTPUT_NAME, so CMake
      produced fujinet-go-coleco-macos.app while release.yml looks for
      "FujiNet Go ColecoVision.app". The name was the failure that showed;
      the layout was the worse bug hiding behind it. The POST_BUILD put
      libfujinet.dylib in Contents/Frameworks and the assets in
      Contents/Resources/fujinet -- the layout a Mac app would normally use
      -- but core/src/paths.c's resolve_lib_dir() probes exe_dir(), which is
      Contents/MacOS, and then takes the asset tree from that same directory
      or its "fujinet" subdir. That bundle would have built, signed,
      notarised and stapled without complaint and then come up with
      "libfujinet not found" on a user's Mac. Both now match astrocade,
      whose path layer resolves the same way.

      Note that macos.yml never looks inside the bundle, which is why it
      stayed green throughout. The release job's self-containment check is
      the only thing in the project that reads that layout.

  [x] Windows: libwinpthread-1.dll was leaking into the exe. The
      -static -static-libgcc -static-libstdc++ lived in
      cmake/toolchains/mingw-w64.cmake as CMAKE_EXE_LINKER_FLAGS_INIT, which
      covers the cross-compile ONLY; the native MSYS2/UCRT64 build that
      actually ships the release uses no toolchain file and so got none of
      it. The desk-side cross build stayed green the whole time while the
      shipped .exe would not have started outside an MSYS2 shell. Moved onto
      the target with target_link_options, where the rest of the family puts
      it, and onto the thread-starting test targets for the reason
      adam-desktop gives: a test that needs a DLL from an MSYS2 shell is not
      a test you can run on a bare Windows box or under wine.

      Verified rather than assumed: cross-built boot_smoke against a copy of
      the toolchain with the static line stripped -- standing in for the
      native MSYS2 build -- and objdump -p shows libwinpthread-1.dll with the
      fix disabled and a clean import table with it enabled.

  [x] Windows: the NSIS installer had no script. release.yml already
      installed mingw-w64-ucrt-x86_64-nsis and invoked makensis with all four
      -D defines against build-aux/windows/installer.nsi -- a file that did
      not exist. Ported from astrocade's, renamed: same per-user install into
      %LOCALAPPDATA%\Programs, no UAC prompt, built from the same staged
      folder that becomes the portable zip so the two artifacts are the same
      bits packaged twice. The uninstaller deliberately leaves
      %APPDATA%\fujinet-go-coleco behind, which here also means the imported
      OS7 BIOS the user had to supply and this project cannot ship.

  The general lesson, and the one worth carrying to the next port: a release
  workflow is not the push workflow with an upload step bolted on. It is the
  first thing that reads the bundle layout, the import table and the
  installer, and every one of those was wrong here while all four push
  workflows were green.


Done (this pass) -- a full audit against the rest of the family
--------------------------------------------------------------

  Prompted by the observation that this port was missing "all sorts of
  stuff" from the other family members. It was. The method: diff this repo's
  file list against adam, astrocade and intv with the platform names
  normalised away, then check every difference that was not obviously
  platform-specific. What that turned up, and what was done:

  [x] NO PREFERENCES DIALOG, in any frontend. This was the serious one.
      colecosession_default_opts() reads seven keys out of the settings
      store -- sgm, palette, swap_buttons, enable_fujinet, enable_audio,
      enable_gamepad, cart_path -- and nothing in any of the four frontends
      could change any of them. The only way to turn the Super Game Module
      off was to hand-edit settings.ini, which made the SGM support this
      project was commissioned for effectively unreachable from the app.
      Every other frontend in the family has had this dialog since its first
      release (GNOME prefs.c and KDE SettingsDialog in adam, astrocade,
      intv, coco and apple2). Now in all four, sharing one settings store so
      the machine is configured once however you reach it.

  [x] NO FUJINET LOG WINDOW. colecosession_fujinet_copy_log() had existed
      since M4 and no frontend called it -- a dead export. That matters more
      here than elsewhere in the family, because this port is a protocol
      bring-up: watching the cartridge dial in and the FujiBus transactions
      go past is how you tell "it does not work" from "READ HOST SLOTS
      returned an error". Now in all four.

  [x] NO SCREENSHOTS in either metainfo. GNOME Software, Discover and
      Flathub listings showed nothing at all. Three captured from the
      running app: the CONFIG client on the host list, the debugger's VDP
      viewer, and Preferences.

  [x] NO DEDICATED DISASSEMBLER OR VDP DECODER TESTS. debug_test
      disassembled four instructions at $0000 and touched none of the VDP
      decoders. Both files were transposed wholesale and both are what every
      frontend's debugger pane renders. Added dasm_test and vdp_decode_test,
      then extended the latter past what adam's covers, for the three
      exports no test touched (format_state, format_hex, color_name).

  [x] NO NON-INTERACTIVE GAMEPAD TEST. gamepad_probe needs a human, so the
      gamepad backend had no automated coverage. Added the family's
      gamepad_map_test, which drives an SDL3 virtual pad.

  [x] NO SYMBOL GENERATOR. symbols_builtin.c carried a "Generated by
      tools/symbols/extract-symbols.py" header naming a script that did not
      exist in this repo -- an unreproducible provenance claim in a project
      whose COMPLIANCE.md turns on those tables being tables of facts. Added
      it (adam's, with the EOS half removed -- the ColecoVision has no EOS);
      it regenerates the committed file byte for byte and --check enforces
      that. Also commits core/debugger/symbols/os7.sym, 476 symbols.

  Checked and deliberately NOT adopted:

  * astrocade's embedded gamecontrollerdb. One of eight repos has it, and it
    adds a third-party data file and a licence row for a gain SDL3's own
    mapping database already covers.
  * core/src/bindings.h and gamepad_sdl.h. Not missing: this port declares
    that API in the public colecosession.h instead of a private header. A
    structural difference, not an omission.
  * The GNOME webview and QtWebEngine config window (3 and 4 of 8 repos).
    All four frontends here open the FujiNet web UI in the system browser,
    which avoids a very large optional dependency and is what astrocade does
    too.
  * ecskbd, stic, jzintv and disc-geometry files -- other machines' hardware.


Remaining
---------

  * Three SGM questions, none blocking, nothing observed depends on them:
    whether $50-$53 mirror across $40-$5F, whether $7F is readable on an SGM,
    and the exact AY volume law.
  * The macOS app has been BUILT but never RUN -- no Mac here. Its tests pass
    on the runner; nobody has looked at the window.
  * Binding gamepad buttons as well as keys (the table has room; only keys
    are wired).
