• src/doors/syncmoo1/syncmo

    From Rob Swindell (on Debian L@VERT to Git commit to main/sbbs/m on Thu Jul 9 01:55:00 2026
    https://gitlab.synchro.net/main/sbbs/-/commit/d04b760f7ccdff599cc62b66
    Added Files:
    src/doors/syncmoo1/syncmoo1_audio.c syncmoo1_audio.h
    Modified Files:
    src/doors/syncmoo1/CMakeLists.txt src/doors/syncmoo1/tests/CMakeLists.txt test_audio.c
    Log Message:
    syncmoo1: add syncmoo1_audio -- content-addressed SFX over termgfx

    The module 1oom's hw_audio_sfx_* hooks will forward to. Samples are content-addressed as s_<fnv1a8>, so identical bytes in INTROSND.LBX and SOUNDFX.LBX share one client-cache entry and the name never depends on
    1oom's index numbering.

    Two engine facts shape it, both read from the source:

    - batch_start(max) is a capacity hint, not a reset. ui_late_init()
    registers the 41 gameplay sounds before the intro registers its
    three, so clearing here would silence the game once the intro ran.
    There is a test for exactly that.

    - The audio tier is unknown while the engine registers sounds, so
    every Store would be dropped. Samples are copied into a pending
    queue and drained by sm_audio_pump() once the tier is known.

    No manager attached (a terminal with no audio APC) is a silent no-op throughout, which is also what lets the unit test drive the module with
    no socket. Tests run with -UNDEBUG so the assert()s actually execute.

    sm_audio_slot() returns NULL for an empty slot, so the test NULL-checks
    before every strcmp(). Without that, the batch_start regression test
    would "fail" by segfaulting inside strcmp before the assertion could
    name the defect it exists to catch.

    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

    ---
    þ Synchronet þ Vertrauen þ Home of Synchronet þ [vert/cvs/bbs].synchro.net
  • From Rob Swindell (on Debian L@VERT to Git commit to main/sbbs/m on Thu Jul 9 01:55:00 2026
    https://gitlab.synchro.net/main/sbbs/-/commit/da21fdea0caa28d2fa4f3d35
    Modified Files:
    src/doors/syncmoo1/syncmoo1_audio.c syncmoo1_audio.h src/doors/syncmoo1/tests/test_audio.c
    Log Message:
    syncmoo1: strip LBXVOC sub-header before hashing/queuing SFX

    Root cause of the "uploads correct APCs, plays silence" bug, confirmed
    on a real session's wire capture: 1oom hands hw_audio_sfx_init() a raw
    LBX item, not a bare Creative VOC. An LBXVOC item is a 16-byte LBX
    sub-header (magic af de 02 00, 1oom/src/fmt_id.h HDRID_LBXVOC) glued
    onto the front of the actual VOC bytes. 1oom's own fmt_sfx_convert()
    knows this and skips HDR_LBXVOC_LEN (fmt_sfx.c:406-428); syncmoo1_audio
    never called that path (termgfx's Store already transcodes VOC) and so
    never stripped the wrapper either. termgfx_audio_voc_to_pcm() only
    recognises the VOC magic at offset 0, so its detector never fired and sfx_store_bytes() shipped the wrapped blob verbatim -- 622 bytes of
    non-audio framed as an audio Store.

    Add sm_audio_payload_offset(data, len): returns 16 when the buffer
    starts with the LBXVOC magic and len > 16 (so len - 16 never
    underflows), else 0. The magic/length are local constants sourced from fmt_id.h/fmt_sfx.c by comment reference only -- nothing under 1oom/ is
    included or linked, so test_audio stays dependency-free.

    sm_audio_init() now applies that offset before both the content hash
    and the pending-queue copy, so the hash covers the bytes actually
    uploaded and termgfx receives a payload starting "Creative Voice
    File". Every moo1/sfx/s_<hash> leaf name changes as a result -- the
    old names identified bytes that never played, so this is expected.

    Verified end to end: unit tests (offset math, no-underflow, and an
    LBX-wrapped sample deduping against its unwrapped VOC twin to one
    queue entry) pass, and a wire-capture harness against the freshly
    built binary shows the first Store payload now begins "RIFF" with
    "WAVE" at offset 8 instead of the LBXVOC magic, with all 35 Stores
    still uniquely named moo1/sfx/s_<8 hex>.

    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

    ---
    þ Synchronet þ Vertrauen þ Home of Synchronet þ [vert/cvs/bbs].synchro.net
  • From Rob Swindell (on Debian L@VERT to Git commit to main/sbbs/m on Thu Jul 9 01:55:00 2026
    https://gitlab.synchro.net/main/sbbs/-/commit/f4d189b62669e4e8690c26f3
    Added Files:
    src/doors/syncmoo1/syncmoo1_geom.c syncmoo1_geom.h src/doors/syncmoo1/tests/test_geom.c
    Modified Files:
    src/doors/syncmoo1/CMakeLists.txt syncmoo1_io.c src/doors/syncmoo1/tests/CMakeLists.txt
    Log Message:
    syncmoo1: never encode the sixel below the native frame

    The sixel encode is resampled from 1oom's native 320x200 indexed frame
    with a nearest-neighbour step, which DROPS a source row or column
    whenever the encode is smaller than the source on that axis. MoO1's UI
    font draws 1-pixel-wide strokes, so a dropped column takes a whole glyph
    with it: the options screen showed "SFX volume 00" for a volume of 100,
    the title screen "1oom v1.1.8", and the options menu "Inaut" for "Input".

    Two independent causes, both now removed:

    Width. The fit preserves the source aspect, so any page shorter than
    400px is height-limited and comes out narrower than the native 2x. An
    80x24 terminal (an 80x25 SyncTERM showing its status line) fits to
    588x368 and encodes 294 columns, dropping 26 of 320. The <=8% letterbox stretch was meant to cover this and doesn't: 640 vs 588 is an 8.8% bar.
    Widen a height-limited fit back out to the native 2x instead of tuning
    the percentage -- but only while the frame still gets its native height,
    so a degenerately short page can't be squashed below 1:1 vertically.

    Height. The pixel-aspect upscale we ask the terminal for was a hardcoded pan=pad=2, so the encode was always half the displayed size -- and half
    of a 368px-tall fit is 184, below the 200 rows we started with. Even the healthy 80x25 case dropped 8. Each axis now takes the 2x only when the resulting encode still covers the native frame on that axis, and falls to
    1 (upsampling, which duplicates and never loses) when it wouldn't. The
    6-row sixel band clamp factors into that choice: trimming a 200-row
    encode to a whole 198 drops two source rows just as surely.

    Source pixels discarded per frame, before -> after:

    80x24 294x180, 26 columns + 20 rows -> 320x366, none
    80x25 320x192, 0 columns + 8 rows -> 320x384, none

    Costs ~1.6x the sixel bytes per frame on a page under 400px tall (28.9K
    45.9K at 80x25); run-length coding absorbs the rest of the doubling.
    Frame de-duplication still skips static screens and the DSR-ACK pacing
    adapts, so a slow link loses frame rate rather than backing up. A page
    tall enough to clear 200 rows at pan=2 -- a 116x36 foot session, say --
    keeps the cheap 2x on both axes and costs exactly what it did before.

    The fit and encode math moves to a new pure syncmoo1_geom.c, since syncmoo1_io.c pulls in termgfx's audio manager (and with it
    C++/libADLMIDI) and can't be linked into a unit test. tests/test_geom.c
    locks down the invariant on both axes across every 8x16 page from 20
    rows up; it is what caught the band-clamp case above.

    Verified end-to-end against a fake SyncTERM driving the real encode path:
    at 80x24 the raster goes 294x180 -> 320x366 and "1oom v1.11.8" renders
    with all three 1s. The 80x25 sixel is byte-identical to before at the
    old pan/pad, and the sm_geom_encode_dims() change is what moves it.

    Not verified on a live SyncTERM. Note also that SyncDOOM and SyncDuke
    carry the same defect in their own copies of this math (both encode
    320x192 at 80x25, dropping 8 rows); their JXL tier is immune, being a 1:1
    blit the door upscales itself.

    ---
    þ Synchronet þ Vertrauen þ Home of Synchronet þ [vert/cvs/bbs].synchro.net
  • From Rob Swindell (on Debian L@VERT to Git commit to main/sbbs/m on Thu Jul 9 01:55:00 2026
    https://gitlab.synchro.net/main/sbbs/-/commit/474e8cf07692c08a07b98fc4
    Added Files:
    src/doors/syncmoo1/syncmoo1.example.ini
    Modified Files:
    src/doors/syncmoo1/CMakeLists.txt README.md syncmoo1_config.c syncmoo1_config.h syncmoo1_io.c
    Log Message:
    syncmoo1: add syncmoo1.ini; gate the wire dump behind [debug] wire

    The wire dump was unconditional, writing multi-MB per session per node to data/syncmoo1/syncmoo1_n<node>.wire. It is a debugging aid -- it is how the LBXVOC sub-header bug was found -- but it has no business running for every player.

    Give the door the config file it never had (xpdev ini_file, read from the launch dir before the per-user chdir, as syncduke does) and default the dump
    to off. SYNCMOO1_WIREDUMP=<path> still forces it on for a one-off debug run without editing the ini; SYNCMOO1_WIREDUMP=- still forces it off.

    Also read [audio] music_quality here, which the music work uses next.

    syncmoo1_io.c's block comment and the CMakeLists.txt ini_file comment
    describe the gate and why an ini replaced the original env-var-only
    approach, rather than the unconditional dump they used to document.

    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

    ---
    þ Synchronet þ Vertrauen þ Home of Synchronet þ [vert/cvs/bbs].synchro.net
  • From Rob Swindell (on Debian L@VERT to Git commit to main/sbbs/m on Thu Jul 9 01:55:00 2026
    https://gitlab.synchro.net/main/sbbs/-/commit/aa8f8077290791b6225fd81c
    Added Files:
    src/doors/syncmoo1/syncmoo1_cfgprune.c syncmoo1_cfgprune.h src/doors/syncmoo1/tests/test_cfgprune.c
    Modified Files:
    src/doors/syncmoo1/CMakeLists.txt hw_sbbs.c syncmoo1.example.ini syncmoo1_config.c syncmoo1_config.h src/doors/syncmoo1/tests/CMakeLists.txt
    Log Message:
    syncmoo1: make the user's 1oom config a pure diff against sysop policy

    1oom's cfg_save() writes EVERY registered item on exit, so after one session
    a player's config pinned every option -- including ones they never touched.
    A sysop who later changed a default could never reach an existing player.

    Two halves. syncmoo1.ini gains a [1oom] passthrough whose keys are literal
    1oom cfg names (module.item); the door writes them to a temp file and calls 1oom's own cfg_load(), so the engine's per-item range checks validate them
    and every option it has -- now or later -- is settable with no code here.
    Then cfg_save() snapshots the base.

    1oom's cfg_load() assigns only the keys physically present in a file, so the user's config still overrides whatever it names. At exit we prune it against the base, dropping every key the player never diverged on. A player who never opens the options menu ends with an empty config, and the next sysop change reaches them.

    The pruner (syncmoo1_cfgprune.c) is pure and I/O-free so it unit-tests with
    no engine and no termgfx: comments, blank lines, malformed lines, and keys absent from the base are all preserved; it is idempotent; values compare with whitespace stripped. Best-effort at runtime -- any failure leaves the file exactly as 1oom wrote it.

    Wiring the prune call took two rounds to get right, both confirmed by an
    actual headless run rather than static reasoning. First: 1oom's game loop returns to main() with a plain C `return` on a normal quit -- nothing calls exit() at that point, so cfg_save() (atexit(main_shutdown)'d from inside main_1oom()) hasn't run yet when a plain post-call prune would fire. Fix: atexit(sm_config_prune_user_cfg) registered BEFORE main_1oom(), so it lands earlier on the atexit stack and therefore runs LATER (atexit is LIFO) --
    after cfg_save(), whether the process ends via a normal return, the DOOR32 time-limit's exit(0), or anything else that calls exit(). Second: by the
    time our now-later handler runs, main_shutdown()'s own os_shutdown() has already freed 1oom's cached user-config-dir, so a second cfg_cfgname() call silently re-derives a $HOME/XDG path instead of the door's per-user sandbox. Fixed by caching the resolved config path once, during the same early pass
    that snapshots the base, before 1oom's shutdown can clear it.

    Two defaults belong in a door and now ship in syncmoo1.example.ini: ui.copy_protection_disabled, because MoO1's manual-lookup check ends the game after year 40 and a BBS player has no manual; and game.skipintro, which is
    4.6s of fades. Note cfg-backed options must be set here, not as 1oom command line flags -- options_parse() applies those after the base snapshot, so they would read as user divergence and pin themselves into every config.

    ---
    þ Synchronet þ Vertrauen þ Home of Synchronet þ [vert/cvs/bbs].synchro.net
  • From Rob Swindell (on Debian L@VERT to Git commit to main/sbbs/m on Thu Jul 9 01:55:00 2026
    https://gitlab.synchro.net/main/sbbs/-/commit/380c52085ffc8bf1dea97ea2
    Modified Files:
    src/doors/syncmoo1/syncmoo1.h syncmoo1_config.c
    Log Message:
    syncmoo1: never let 1oom's config path fall back to $HOME

    sm_config_apply() only called 1oom's os_set_path_user() when -home was
    given. Without -home, os_get_path_user() lazily resolved and cached $XDG_CONFIG_HOME/1oom or $HOME/.config/1oom on first use, so a
    BBS-launched run with no -home wrote the player's 1oom config and our
    own temp files straight into the invoking (service) account's home
    directory -- already the cause of one accidental overwrite during
    testing.

    Fix os_set_path_user() to always run before anything else in the
    process can call os_get_path_user(): to the absolute -home directory
    when given, otherwise to the door's absolute cwd. 1oom's own $HOME/XDG
    fallback never gets a chance to fire. Update the syncmoo1.h contract
    comment to document this guarantee.

    Follow-up cleanup in sm_config_seed_1oom(), now that the user path is
    never empty in either mode:
    - drop the dead "no -home: nowhere to put the temps" guard/comment
    (os_get_path_user() never actually returned NULL/""), replacing it
    with a defensive comment describing the real (now-true) invariant,
    plus a note on the residual limitation that concurrent sessions
    sharing one cwd also share 1oom_config_*.txt and the temp names.
    - remove the leftover syncmoo1_base.tmp when cfg_save() fails
    mid-write: it opens with "w+" (create/truncate) and can fail via a
    later goto without cleaning up after itself, and blanking
    sm_base_cfg right after gates off sm_config_prune_user_cfg()'s own
    remove() of that path.
    - tidy the atexit-LIFO-ordering comment into a plain statement of the
    (unchanged) invariant instead of reasoning out loud.

    Verified: clean build, 4/4 ctest, and a scripted -new/-savequit run
    both without and with -home -- HOME stays empty either way, config
    lands in cwd or the -home dir as expected, and no *.tmp files are left
    behind in either mode.

    ---
    þ Synchronet þ Vertrauen þ Home of Synchronet þ [vert/cvs/bbs].synchro.net
  • From Rob Swindell (on Debian L@VERT to Git commit to main/sbbs/m on Sun Jul 12 05:11:00 2026
    https://gitlab.synchro.net/main/sbbs/-/commit/11ca7941b118609f3b957220
    Modified Files:
    src/doors/syncmoo1/syncmoo1_geom.c syncmoo1_geom.h syncmoo1_io.c src/doors/syncmoo1/tests/test_geom.c
    Log Message:
    syncmoo1: render full-size on strict-DEC sixel terminals (Windows Terminal)

    The sixel encode leaned on SyncTERM's integer pixel scaling unconditionally:
    it encoded the frame at half size and set the pan;pad raster attribute to 2, trusting the terminal to draw each sixel pixel as a 2x block. SyncTERM/cterm does. A strict-DEC sixel terminal -- Windows Terminal, xterm -- reads pan;pad as a 1:1 pixel aspect and draws the sixel at its ENCODED size, so the picture came out at half the intended dimensions ("really small"), and the in-game
    hand cursor drifted from the hardware cursor by the same 2x, because the mouse mapping assumes the image fills the whole ew x eh rect.

    The sibling doors already guard this (SyncDOOM: hsc = !is_syncterm ? 1 : SIXEL_SCALE); syncmoo1's encode was terminal-agnostic. Thread is_syncterm into sm_geom_encode_dims: SyncTERM keeps the cheap pad/pan=2 encode, every other sixel terminal gets a 1:1 encode at the full displayed size (pad=pan=1). The displayed size is ew x eh in both branches -- sxw*pad == ew -- so the image fills the fitted rect and the cursor lines up on every terminal.

    The 1:1 encode costs ~4x the raster bytes, but MoO1 is turn-based and mostly static, so frame de-dup absorbs it, and there is no correct alternative on a terminal that won't upscale. Unknown-yet reads as not-SyncTERM (the safe full encode) until a CTDA reply proves otherwise, matching the palette-persistence guard; on real SyncTERM the optimized path resumes once detection completes, seamlessly (both branches display the same ew x eh).

    Verified: to a simulated non-SyncTERM terminal the door now emits
    "1;1;640;384 (full 1:1) instead of "2;2;320; the geom unit test covers both branches; ctest 4/4.

    ---
    þ Synchronet þ Vertrauen þ Home of Synchronet þ [vert/cvs/bbs].synchro.net
  • From Rob Swindell (on Debian L@VERT to Git commit to main/sbbs/m on Sun Jul 12 05:11:00 2026
    https://gitlab.synchro.net/main/sbbs/-/commit/4a11464eb46b2fb3fbf317c7
    Modified Files:
    src/doors/syncmoo1/syncmoo1_io.c
    Log Message:
    syncmoo1: clear on geometry change, and center with a fractional cell

    Two rendering bugs on a maximized Windows Terminal, both latent in the sibling doors too (only syncretro was fixed, via the shared termgfx_geom_center_ex).

    The black box. Nothing wiped the screen when the geometry changed. The door starts on the default 80x25/640x400 assumption; when the terminal's
    grid/canvas
    probe reply lands mid-intro it recomputes a different image position and size, but the previous frame's footprint -- and 1oom's pre-sixel "Loading Master of Orion" text -- were never cleared, so a stale black rectangle sat in the background behind the new frame. Emit ESC[2J when the drawn geometry differs from the last frame sent (guarded off the first frame, which term_enter
    already
    cleared). Same fix the sibling doors' tier-change path applies; here the trigger
    is the probe-driven geometry change.

    The off-center image. Centering used cw = canvas / grid_cols, an integer that truncates. On a window whose pixels-per-cell isn't an exact 8x16, the lost fraction of a cell put the image a cell or two off ("kind of centered"). Adopt termgfx_geom_center_ex with the true fractional cell so the column/row round correctly.

    Verified: the geometry-change ESC[2J now fires on the wire when a probe reply changes the grid, and the door renders correctly on a real WT sixel session (user-confirmed). ctest 4/4.

    ---
    þ Synchronet þ Vertrauen þ Home of Synchronet þ [vert/cvs/bbs].synchro.net
  • From Rob Swindell (on Debian L@VERT to Git commit to main/sbbs/m on Tue Jul 21 01:49:00 2026
    https://gitlab.synchro.net/main/sbbs/-/commit/d6ab9c05fab10fa46155986f
    Modified Files:
    src/doors/syncmoo1/syncmoo1_io.c
    Log Message:
    syncmoo1: drain paced-frame acks before the sixel vscale probe

    On xterm (and any terminal that draws a sixel at its encoded size rather
    than honoring the raster pan), the picture intermittently rendered at
    half height and the in-game mouse cursor tracked at twice the vertical
    rate -- the game's idea of the pointer drifted from the hardware cursor.

    Both symptoms were one cause: a wrong vertical-scaling verdict. The probe paints two thin slivers (pan=1, then pan=2), each bracketed by a cursor- position report, and infers "scales" when the pan=2 sliver advanced the
    cursor further than the pan=1 one. The three reports must be the probe's
    own [home, after-pan1, after-pan2].

    But sm_io_vscale_probe() fires from present(), AFTER the first few frames
    have already gone out and been paced -- and each paced frame is acked by a cursor report of its own. When a frame's ack was still in flight as the
    probe armed, sm_input_vscale_collect() claimed that straggling pace-ack as
    the probe's `home`, shifting the window by one: the true [1,3,5] (equal
    two-row advances -- xterm ignores pan) was read as [1,1,3] and looked like
    it had scaled. The door then sent a half-height pan=2 encode to a terminal
    that draws at the encoded size, and sm_map_mouse() -- which assumes the
    image fills the full fitted height -- put the cursor at 2x the Y rate. The
    race made it intermittent: whether a leftover ack landed inside the three- report window depended on timing.

    Fix: quiesce the pace pipeline before arming. Wait for every outstanding present()-sent DSR to be acked (draining routes those reports to sm_io_pace_ack(), since the collector is not yet armed), so the probe's
    own three reports are the only cursor reports in flight once it arms. The synchronous probe that follows blocks further presents, so no new DSR can appear before the verdict. Bounded by the probe's existing grace; a silent terminal just proceeds to the safe full-size fallback.

    This restores the invariant the sibling doors already hold a different way
    -- SyncDOOM and SyncDuke run the probe before the first frame (SyncDuke
    holds frame one for it; SyncDOOM reads its own accumulator with nothing in flight), so a stray report is inert there. syncmoo1 paints early frames,
    so it drains instead. Verified on xterm -ti vt340: the probe now collects
    a clean [1,3,5], the verdict is "does not scale", and frames stay pan=1 at
    full height with the cursor aligned.

    ---
    þ Synchronet þ Vertrauen þ Home of Synchronet þ [vert/cvs/bbs].synchro.net
  • From Rob Swindell (on Debian L@VERT to Git commit to main/sbbs/m on Fri Jul 24 23:28:00 2026
    https://gitlab.synchro.net/main/sbbs/-/commit/b7cd963bb9f7cecd5ae954f0
    Modified Files:
    src/doors/syncmoo1/syncmoo1_config.c syncmoo1_io.c src/doors/termgfx/gfxgate.c gfxgate.h termgfx_termio.c src/doors/termgfx/test/test_gfxgate.c
    Log Message:
    termgfx: ask a door what it can draw with, not which tiers exist

    The gate took have_sixel and have_jxl and decided for itself what counted
    as graphics. That only held while every door drew the same two ways. SyncConquer also serves an APC PPM tier, and enumerating each door's tier ladder here would mean this file going stale every time one gained a
    tier. Take a single have_graphics instead: whether the door has anything
    to draw with is the door's question, and the settle timing and the
    verdict -- the parts worth agreeing on -- stay here.

    Also hoist the default notice file name to TERMGFX_GFXGATE_FILE, so
    "drop this file next to the ini" means the same thing at every door
    rather than each spelling it out.

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    ---
    þ Synchronet þ Vertrauen þ Home of Synchronet þ [vert/cvs/bbs].synchro.net