Files
dtourolle 5a8c3e4c40 Run each model on the Hexagon in the form measured to hold it
The engine knew f32 and int8, and gave the Hexagon int8 for every role it
served. Measured on the tablet itself (inference.md §1.5), int8 lost
5% of the detector's faces at 40-80 px, moved the landmarks 1.5 px,
emptied the segmenter's scores and cost the denoiser 5-9 dB; fp16 the HTP
refuses outright. `Form` gains A16W8 and A16W16, and `Rung::form` now
names one per role: detectors and landmarks A16W8, the segmenter, scene
model, border filler and denoiser A16W16, XFeat int8. The embedder and
the eye classifiers stay on the CPU.

Each loader resolves its `<stem>.<form>.onnx` sibling; the segmenter and
XFeat, compiled into the binary, embed their quantised forms on Android
only and pick through `choose_embedded`. The probe, the compile step and
the cache fingerprint follow the form instead of assuming int8. Detectors
on the new form write `scrfd_*_a16+w600k_mbf`, and `model_ids` answers
for all three spellings.

On the tablet (ORT 1.29 + QNN 2.42), each shipped file against f32 on the
same inputs, and against the CPU's f32 time:
  SCRFD 500m/2.5g/10g  A16W8   100% of faces in every band   4.2/5.1/9.0 ms vs 17/56/198
  landmarks            A16W8   0.25 px in the 192 crop        0.5 ms vs 2.8
  YOLO26n-seg          A16W16  98.2% found, mask IoU 0.994    12.9 ms vs 90
  scene model          A16W16  98.9% of cells agree           15 ms vs 151
  MI-GAN               A16W16  41 dB from f32 in the fill     87 ms vs 488
  XFeat                int8    pano alignment 0.45 px (f32's own spread 0.41)  6.5 ms vs 58
  denoiser             A16W16  0.00 dB at every ISO            95 ms vs 1510 a tile
Face numbers are over public COCO val2017 photographs, not a library.

The APK carries the siblings (BUNDLED 15 -> 19; the old int8 detectors
removed), about 43 MB more. The Windows installer and its CI count skip
them; the Arch and Flatpak packages list their files and never had them.
The ladder example takes a role per model, which is how the per-role
forms above were seen landing on the NPU from the real probe.
2026-10-04 03:45:46 -04:00

31 KiB
Raw Permalink Blame History

DarkRoom — A Windows installer from the Linux CI

Satisfies: FR-PLAT-WIN-1 · FR-PLAT-WIN-2 · FR-PLAT-WIN-3 · NFR-COMPAT-2 (a stated channel) Companion to: distribution.md · requirements.md §3.8, §4.4 · android-signing.md

Spec for producing DarkRoom-<version>-x86_64-setup.exe from the same Gitea runner that builds the Arch package and the APK, with no Windows machine in the loop. It names the toolchain, what the tree has to change to compile for the target, what the installer does, how the CI job is shaped, and — because there is no Windows hardware on the runner — exactly how much of the result can be verified before a person double-clicks it.

Written as a spec; §10 is the report. Every step of §9 has since been run — docker/windows/ is the container, packaging/windows/darkroom.nsi the installer, .gitea/workflows/windows-image.yml and the windows job in build-and-test.yml the CI leg, and §6's gate passes through row 4 under Wine. Four claims in the first draft were wrong and are corrected in place with a note; §10 lists them. Where a claim still rests on reading rather than running, it says so.


1. Why this is nearly free, and where it is not

The reason to write this at all is that the tree is closer to Windows than a Linux-only project usually is. The three things that ordinarily make a cross-build to Windows a week of work are all absent:

Usual obstacle Here
A C image library (libraw, libjpeg-turbo, lcms) rawler, zune-jpeg, jpeg-encoder, all pure Rust
OpenSSL, or a TLS stack with a system dependency reqwest on rustls + webpki-roots; ring cross-compiles to the GNU target
A GUI toolkit with a platform-specific build Slint on winit + wgpu, which already runs the same code on Linux and Android

rusqlite is bundled, so SQLite compiles with whatever C compiler the target has; that is the one place a cross C compiler is required, and it is a package install rather than a port. The inference engine is tract, in Rust, which is what faces.md §3 chose it for — and this is the second time that choice pays: the C++ ONNX Runtime would have needed a prebuilt Windows binary fetched at build time.

Where it is not free is dr-plat and the handful of paths above it, which is exactly where NFR-PORT-1 says platform code should be and where §3 finds it. That the list in §3 is short and every item on it is already behind a cfg is the measure of whether NFR-PORT-3 ("a third platform requires implementing the platform interfaces only") was met. It was, nearly: the gaps are in things that grew above dr-plat — a settings file path, an xdg-open — rather than in the interfaces themselves.


2. Toolchain: the GNU target, from a container

Two Rust targets can produce a Windows binary from Linux.

Target Linker What it needs on the runner What it costs
x86_64-pc-windows-gnu MinGW-w64 gcc gcc-mingw-w64-x86-64 (Debian/Ubuntu), mingw-w64-gcc (Arch) — one apt/pacman install Binaries link libgcc_s and libwinpthread unless told not to; the SEH unwinder is MinGW's rather than MSVC's; DirectX bindings are less exercised (not used — §2.1)
x86_64-pc-windows-msvc lld-link via cargo-xwin The MSVC CRT and Windows SDK headers, fetched from Microsoft's servers by xwin on first use (~1.5 GB, licence-accepted by flag) A download step in CI that depends on Microsoft keeping those URLs stable, and a licence the runner accepts on the project's behalf

Decision: GNU. It is a package install, it is what rustup target add supports out of the box, and every crate in the dependency graph that carries a C component (ring, libsqlite3-sys, zstd-sys if present) builds against MinGW today. The MSVC route produces a marginally more conventional binary — the same CRT every other Windows application links — and costs a 1.5 GB fetch of Microsoft-licensed headers on every cold CI run. That is the wrong trade for a channel whose users are, for now, the author.

Static-link the MinGW runtime so the installer carries one file rather than three. The configuration lives in the container as environment variables rather than in a .cargo/config.toml — that file is untracked here on purpose, and the Android image sets its linkers the same way:

CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc-posix
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUSTFLAGS="-C link-args=-static-libgcc -C link-args=-static-libstdc++"

Corrected. The first draft added -Wl,--whole-archive -lwinpthread "so nothing imports libwinpthread-1.dll". That flag breaks the link: forcing the whole archive drags in unused winpthread objects whose kernel32 and msvcrt references land after those libraries on the link line, and the build dies on a hundred undefined __imp_ symbols. It was also unnecessary — rustc's windows-gnu target links its own winpthread in self-contained mode, and the built executable imports no MinGW library at all (§10). -posix is stated because Debian's bare x86_64-w64-mingw32-gcc is an alternatives symlink to either thread model.

Two more things the container needs that the draft did not name: a host gcc, because build scripts and proc-macros compile for Linux whatever the target and the very first one fails with "linker cc not found" without it; and Wine 10, because rustc's std imports bcryptprimitives.dll for its random source and Debian bookworm's Wine 8.0 does not have it — the smoke test dies at load with c0000135 before the first instruction. So the image is debian:trixie-slim, which also ships Node 20 natively.

2.1 What the binary reaches at runtime

Nothing the installer has to carry. wgpu opens Vulkan (dr_gpu::new_shared, D1 — Vulkan on both targets, and the shared-device path permits nothing else), and on Windows the Vulkan loader vulkan-1.dll is installed by every GPU vendor's driver. Slint's femtovg renderer finds system fonts through fontdb, so the fontconfig the Linux CI job installs has no Windows counterpart. There is no libxkbcommon, no display-server library: winit speaks Win32 directly.

DirectX 12 is deliberately not enabled. wgpu supports it and on Windows would be the conventional choice, but the develop pipeline's compute shaders are written once for Vulkan (NFR-PORT-2) and validated on two Vulkan drivers already; a third backend is a third set of driver behaviours to characterise (NFR-R1's tolerance argument), and no Windows machine that can run this application lacks a Vulkan ICD. The same reasoning that keeps GL out of new_shared keeps DX12 out here. It is one flag away if that turns out to be wrong.

2.2 Where it builds

The same shape as the Android leg: a job container built from a Dockerfile in the tree and pushed to the Gitea registry, tagged by the tree id of its directory so an unrelated push reuses it (android-image.yml already does this and the comment there explains why).

docker/windows/
  Dockerfile        debian:trixie + rustup (1.92.0, target x86_64-pc-windows-gnu) + gcc + gcc-mingw-w64-x86-64 + nsis + wine
  build.sh          run a command in the container; caches registry, target and the Wine prefix
  package.sh        the LFS guard, staging, then makensis

makensis is a Linux binary; NSIS has always been buildable and runnable on POSIX hosts, and Debian ships it as nsis. Wine is in the image for §6, not for the build. osslsigncode is not in it until there is a certificate to give it (§5.4).


3. What the tree has to change

Read from the source, not run. Everything here is dr-plat or the thin layer above it — nothing in core/ is touched, which is NFR-PORT-1 holding.

3.1 Already handled

  • volumes.rs — card detection reads /proc/mounts and /sys/block under cfg(target_os = "linux") and returns an empty list elsewhere. Windows gets no card detection in this pass; the import page's Browse… still reaches a card. (A GetDriveType/DRIVE_REMOVABLE implementation is a screen of code and a follow-up.)
  • Folder dialogues — since 0.17.0 every folder the desktop asks for (the library, an import's source and second copy, Lightroom presets, an album's folder) is chosen in the platform's own dialogue through rfd (folder_dialog.rs), which on Windows is the common item dialogue rather than the XDG portal. Not verified: nothing has opened one under Wine or on Windows.
  • display.rs — the X11 and Wayland colour-profile readers are cfg(all(unix, not(android))); the fallback is FR-DSP-8's stated one. Windows ICC profiles via GetICMProfile are a follow-up for the same reason.
  • desktop_client.rs — the Nextcloud desktop client's Unix socket is cfg(unix). On Windows the client listens on a named pipe (\\.\pipe\...); until that is implemented FR-NC-6c's integration is absent and the app behaves as it does on a Linux machine with no client running.
  • secrets.rs — has a PlatformSecretStore for "any platform without an implementation" that returns SecretError::Unavailable on every call. It is loud on purpose, so a Windows build made with no further change compiles, starts, and fails at sign-in with a clear message. §3.2 is what turns that into a working store.
  • keyring, x11rb, wayland-* are target-scoped dependencies already, so the Linux-only crates are not even compiled.

3.2 Required before the installer is worth shipping

Ordered by what blocks a first sign-in. All four are done; each item says how.

  1. Secret store. Done. keyring 4's v1 feature set — the one the workspace already asks for — includes windows-native-keyring-store, so the Credential Manager backend needed no new feature name, only the crate as a cfg(windows) target dependency and the existing Secret Service implementation's cfg widened to include Windows. One implementation over both, because keyring::Entry is the same API over either; the only difference is that is_available's probe always succeeds on Windows, which is correct — Credential Manager is always present, so FR-NC-2's degraded mode does not arise.

  2. Paths. Done — platform/dr-plat/src/dirs.rs. FR-PLAT-LIN-1 says XDG, and the code said it in five places by reading XDG_*_HOME and falling back to $HOME/.local/.... On Windows HOME is normally unset, so every one of these degraded to a relative path from the working directory — which for a Start Menu launch is C:\Windows\System32. Now one function per kind of directory in dr-plat, with the Windows branch reading %APPDATA% (config; roams) and %LOCALAPPDATA% (data, cache, state; does not), and the five call sites using it. The Android overrides (set_state_dir, set_data_dir) stay where they were; only the fallback behind them moved. Both platforms' rules are unit-tested on either host, and the Windows one was confirmed by running the application under Wine: the log landed in AppData\Local\darkroom\state and nothing was written anywhere else.

    Kind Linux today Windows
    config (settings.json, accounts) $XDG_CONFIG_HOME/darkroom — dr_sync::config_dir, settings_store.rs %APPDATA%\darkroom
    data (catalog, thumbnails, faces) $XDG_DATA_HOME/darkroom — library::data_root %LOCALAPPDATA%\darkroom
    state (crash reports, diagnostics) $XDG_STATE_HOME/darkroom — state.rs, crash.rs %LOCALAPPDATA%\darkroom\state

    FR-PLAT-WIN-1 states this as the requirement. The catalog and thumbnail formats do not change, so a library directory copied from a Linux machine opens.

  3. Face models. Done. library::system_face_models_dirs walked $XDG_DATA_DIRS, which does not exist on Windows. The rule moved to dr_plat::system_data_dirs: the installer puts the models beside the executable (§5), so the Windows branch returns the executable's own directory. The user-directory lookups above it are unchanged, so a hand-placed pair still outranks the installed one, exactly as on Linux.

  4. Opening the sign-in URL. Done. launch_ui.rs shelled out to xdg-open. The Windows branch runs rundll32 url.dll,FileProtocolHandler <url>, which is ShellExecute on the URL and needs no crate — chosen over cmd /C start, whose quoting of & in a query string is a known trap, and over the open crate, which would be a dependency for one line. Android has its own Intent path already, so this is the third branch of a function that already had two. Not verified: Wine has no browser to open.

  5. std::os::unix uses outside a cfg. diagnostics.rs and presets.rs use PermissionsExt for mode bits on written files. Most are inside #[cfg(unix)] blocks already; the first cross-compile will name any that are not, and the fix is a cfg rather than a Windows ACL equivalent — the files in question are the user's own.

  6. The executable's identity. Done. Windows takes the icon and the version block from a resource compiled into the .exe, not from a .desktop file. apps/darkroom-desktop/build.rs uses winresource (which invokes MinGW's windres when cross-compiling) to embed ui/dr-ui/ui/app-icon.png — wrapped into an .ico in OUT_DIR at build time, since an ICO entry may be a PNG, so no generated binary is committed — plus the version from CARGO_PKG_VERSION and the product name. The script returns before touching the crate on every other target, and winresource is an unconditional build-dependency because a cfg(windows) on a build-dependency is evaluated against the host, which is Linux. This is the fifth place the identifier lives, and tools/set-version.sh does not need to learn it: the resource reads the version cargo already knows. The same commit made the release binary a GUI-subsystem executable (windows_subsystem = "windows"), or Windows keeps a console window open behind the application.

Everything in this list is cfg(windows) code in dr-plat or a call-site switch in dr-ui, and none of it touches the image core, the catalog schema, or the edit pipeline. That is the NFR-PORT-3 test, and it should be stated in the commit that closes the list whether it passed.

What the first cross-compile actually found (§9 step 2): nothing in this list blocked the link. The whole graph compiled; the only warnings were two constants — SERVICE in secrets.rs and TIMEOUT in desktop_client.rs — left unused by the cfgs that already shadow their users, now guarded the same way. Items 1–4 are still open, and the binary starts without them; it just cannot sign in.

3.3 Explicitly not in this pass

  • MIME/file-type registration — FR-PLAT-LIN-1's .desktop MIME entries have a registry equivalent (HKCU\Software\Classes\.cr2 etc.). Not until the application opens a file from the command line usefully, which main.rs accepts but the launch flow does not yet act on.
  • High-DPI declaration — winit sets per-monitor-v2 awareness through its manifest by default. Verified in winit's source, not on a monitor; if text is blurry on a 150% display this is the first suspect.
  • Card detection, ICC profiles, the desktop-client pipe — §3.1's three follow-ups.
  • A GL or DX12 fallback — §2.1. A machine without Vulkan gets the library and no develop path, which is what it gets on Linux too.

4. The build script

docker/windows/build.sh, in the shape of the Android one and with the same rules:

cargo build --release --target x86_64-pc-windows-gnu -p darkroom-desktop

Release only, with CARGO_TARGET_DIR inside the workspace so the CI cache key (windows-${{ hashFiles('**/Cargo.lock') }}) covers it. The whole workspace is not built for the target: darkroom-android cannot be, and the examples that need a display or a catalog on disk have nothing to run against. cargo clippy --target x86_64-pc-windows-gnu -p darkroom-desktop is worth running in the same job, because the cfg(windows) branches from §3 are otherwise never linted — the Linux job cannot see them.

Not cargo test --target x86_64-pc-windows-gnu: the test binaries would be Windows executables, and running them means Wine. §6 does that for exactly one binary, deliberately.


5. The installer

packaging/windows/darkroom.nsi, compiled by makensis on the runner into DarkRoom-<version>-x86_64-setup.exe. package.sh passes the version in (/DVERSION=…, from tools/set-version.sh's single source, the workspace Cargo.toml) and refuses to run if any models/face/*.onnx is smaller than 100 KB — the LFS-pointer guard every other packager carries, for the reason distribution.md §1 gives.

5.1 Per-user, not per-machine

Install to $LOCALAPPDATA\Programs\DarkRoom, register the uninstaller under HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\DarkRoom, RequestExecutionLevel user. No UAC prompt, no Program Files, no writes outside the user's profile. This is the shape VS Code's "User Installer" and most Electron applications use, and it is right for this project for two reasons: an unsigned installer that also asks for administrator rights is the most alarming thing Windows can show a user (§5.4), and a per-user install means the application's own data directories (§3.2) and its binaries are governed by the same account, which is what NFR-SEC-5's "the user's own hardware" means on a shared machine.

5.2 What it puts on disk

$LOCALAPPDATA\Programs\DarkRoom\
  darkroom.exe
  models\
    scrfd_500m_640.onnx   scrfd_2.5g_640.onnx   scrfd_10g_640.onnx   arcface_mbf_b1.onnx
    2d106det_b1.onnx   ocec_s_b1.onnx   sgc_l_48_b1.onnx
    yolo26s-sem-ade20k.onnx   yolo26s-sem-ade20k.classes.json   categories.txt
    migan-512.onnx
  manual\
    index.html   media\   (the rendered manual and its pictures)
  LICENSE
  uninstall.exe

Plus a Start Menu shortcut, and nothing on the desktop unless the user ticks it. The models are the f32 files the APK bundles and the PKGBUILD installs — not the APK's quantised siblings, which only a Hexagon runs; models\ beside the executable is where §3.2's lookup finds them. No LICENSE yet: the repository has no licence file at its root (the Arch package points at the system's shared GPL text), so the installer has no licence page until one is added — a one-file change, and the .nsi says where the page then goes. The face weights carry the research-only grant that faces.md §2 records, and this channel changes nothing about that: the installer is for the author's own machines until §2.2a's caveat is resolved, exactly as the APK is.

5.3 Uninstall

Removes the install directory, the shortcut and the registry key. Does not touch %LOCALAPPDATA%\darkroom or %APPDATA%\darkroom — the catalog, the thumbnails, the face index, the settings. An uninstaller that deletes a library index the user spent two hours building is the kind of destructive default FR-CULL-12 and NFR-SEC-5's "disabling deletes nothing" both argue against. The uninstaller says so on its one page, and names the two directories so a user who does want them gone knows where they are.

5.4 Signing, and the warning that results from not doing it

An unsigned installer triggers SmartScreen's "Windows protected your PC" interstitial, dismissable through "More info → Run anyway". Signing needs an Authenticode certificate, which is paid and identity-verified; from Linux the signing itself is osslsigncode, which is why it is in the container image, but there is no certificate to give it. This spec ships unsigned and the release notes say what the interstitial looks like. An OV certificate is a cost decision to make if this channel ever has a user who is not the author; an EV one buys instant reputation and costs a hardware token. Neither is a build problem.

The APK went through the same sequence — android-signing.md records a debug-signed build becoming a release-signed one when it mattered — and this channel should be allowed to do the same.

5.5 What NSIS is chosen over

WiX produces an MSI, which is what enterprise deployment tooling wants and what nobody deploying a photo editor to their own laptop cares about; its Linux story is wixl from msitools, which is real but thinly used. Inno Setup runs only under Wine. NSIS is scriptable in plain text, builds natively on Linux, produces a single self-contained .exe, and the script for §5.2 is under a hundred lines. It is the conventional answer for exactly this situation.

One choice inside NSIS: Target amd64-unicode, a 64-bit installer rather than the default 32-bit stub. The application is x86_64 only so nothing is lost, and it is what lets §6's install test run under a 64-bit-only Wine — the 32-bit stub needs an i386 multiarch Wine and dies loading the WoW64 ntdll without one.


6. Verifying without Windows

This is the part to be honest about. The runner has no Windows, no GPU it can hand to a Windows process, and no display. What can be checked, in increasing cost and decreasing certainty:

Check How What it proves
It links the build succeeds Every cfg(windows) branch compiles; no unix-only symbol leaked past a cfg
It is a Windows executable file darkroom.exe reports PE32+; x86_64-w64-mingw32-objdump -p lists the DLLs it imports and none are MinGW's The static-runtime flags in §2 held
It starts wine64 darkroom.exe --version exits 0 and prints the version The CRT, the resource block and main are sound; paths in §3.2 resolve (Wine sets LOCALAPPDATA)
The installer runs wine64 DarkRoom-setup.exe /S then the install directory exists with the eight files, and wine64 uninstall.exe /S removes it The NSIS script's file list, sections and uninstaller are right
It draws a window xvfb-run wine64 darkroom.exe with SLINT_WGPU_CPU and a lavapipe ICD exposed through winevulkan That Slint's winit backend initialises on Win32 — and this is where the chain gets long enough that a failure says more about Wine than about DarkRoom

The first four are the CI gate. The fifth is worth trying once by hand and not putting in CI: it needs winevulkan to find a host ICD, xvfb, and a Wine prefix warmed up in the container, and every one of those is a moving part that has nothing to do with whether the application works on Windows.

--version exists for this — a smoke test needs an exit that opens no window and touches no directory — and it is answered before the logger and the crash hook install, so it proves the CRT and the resource block and nothing above them.

One more row the table missed: the Start Menu shortcut. CreateShortcut is IShellLink, which does nothing under a headless Wine while the CreateDirectory beside it succeeds, so an installer that installs and uninstalls cleanly here can still have a broken shortcut. That row is on Windows only.

What none of this proves: that wgpu opens a Vulkan device on a real driver, that a 6000-px render completes, that fonts are found, that the secret store round-trips. Those are a person with a Windows machine, once per release, until there is a Windows runner — and a self-hosted Windows act_runner is how that would be done, not a cloud service. The release notes for the first build say which of these were checked and on what.


7. The CI job

A fourth leg of build-and-test.yml, beside desktop, Android and traceability:

  windows-image:
    uses: ./.gitea/workflows/windows-image.yml     # same shape as android-image.yml

  windows:
    runs-on: linux/amd64
    name: Windows (x86_64, cross)
    needs: windows-image
    container:
      image: gitea.tourolle.paris/dtourolle/darkroom-windows:latest
    steps:
      - checkout, LFS pull                           # copied from the desktop leg
      - cache: ~/.cargo, target                      # key: windows-${{ hashFiles('**/Cargo.lock') }}
      - docker/windows/build.sh                      # cargo build + clippy, --target x86_64-pc-windows-gnu
      - smoke: file, objdump, wine64 --version       # §6 rows 1–3
      - docker/windows/package.sh                    # LFS guard, makensis
      - smoke: wine64 setup.exe /S; ls; uninstall    # §6 row 4
      - upload artefact: DarkRoom-*-setup.exe        # on tags only, like the APK

Same gotchas as the Android leg, which its comments already record: the host has no Node, so the checkout is plain git; workflow inputs arrive as strings; the image build needs the host Docker daemon and runs outside a container. None of that is new.

Cost. A cold build of the whole graph for a second target is roughly the desktop leg again — tract, Slint's compiler, wgpu — so with the cargo cache warm it is minutes and cold it is the better part of half an hour. Worth noting because the runner is one machine and the legs run in parallel on it; if it starts starving the desktop leg, needs: desktop serialises them.

Disk is the tighter budget. The runner has one 99 GB disk shared with its container images. At rest it holds about 23 GB; the desktop leg's restored target cache, the models and the dependency build bring it to about 78 GB before a test runs, and v0.18.0's run ended at 92 GB used. v0.18.1's release build then died with "No space left on device", so that tag has no release page. Since then the desktop leg deletes its test executables, target/debug/examples and target/debug/incremental after the Test step and before the release build — they are relinked whenever a source changes, and the cache exists for the dependency rlibs — and prints the disk again beside its Disk before and after lines. A second target's cache on the same disk is the first thing to look at if a leg runs out again.


8. Requirements

Three, added to requirements.md §3.8 under a #### Windows heading beside the Linux ones. Phrased to be testable, and each one is something §3 or §5 would otherwise leave as a convention.

FR-PLAT-WIN-1 — Known folders. Configuration under %APPDATA%\darkroom; data, cache and state under %LOCALAPPDATA%\darkroom. No file under the user's profile root and nothing relative to the working directory. The directory layout beneath those roots is the same as under XDG, so a library directory moves between platforms unchanged.

FR-PLAT-WIN-2 — Installer. A per-user installer that needs no elevation, registers an uninstaller, and whose uninstaller removes what the installer wrote and nothing the application wrote. Models are installed beside the executable and found there last, after the user's own directories.

FR-PLAT-WIN-3 — Built from Linux. The Windows binary and its installer are produced by the Linux CI from the same commit as every other channel, with no Windows machine in the build. Verification on Windows is a release step, recorded per release, not a build step.

NFR-COMPAT-2's channel table in distribution.md §1 gains a row. NFR-PORT-3 gets its first real test, and the commit that closes §3.2 records the answer.


9. Order

  1. --version in main.rs, and the .cargo/config.toml target block. Trivial, and the smoke test in §6 needs both before anything else can be measured.
  2. rustup target add x86_64-pc-windows-gnu, pacman -S mingw-w64-gcc, and a first cargo build --target … on the developer machine — before the container exists, because the list in §3.2 is a reading of the source and the compiler's list will be longer. Fix the cfg fallout as it appears. This is the afternoon that decides whether §1's optimism holds.
  3. §3.2 items 1–4, each its own commit, each stating which NFR-PORT interface it implemented.
  4. §3.2 item 6 — the resource block — and the NSIS script; makensis by hand; wine64 setup.exe /S by hand. Now there is an artefact.
  5. The container, the image workflow, the CI leg. Only after 4 works locally, for the same reason the Android image was reproduced from the tree after it had lived on one laptop.
  6. A build on a real Windows machine, and a note in the release saying what was checked.

Steps 1–2 are cheap and either confirm this document or replace §3.2 with the true list. Nothing past step 2 should be started on the strength of this document alone.


10. Report · 2026-09-12

Steps 1, 2 and 4 run, in the container rather than on the developer machine, because the container was the cheaper way to get a pinned MinGW and a Wine that could be thrown away.

§6 row Result
It links Yes, first attempt once the link flags were right. 115 MB, PE32+ … (GUI). Two dead-code warnings, both cfg-shadowed constants, fixed.
It is a Windows executable 26 imports, all Windows system DLLs. No MinGW runtime. .rsrc carries PRODUCTVERSION 0,12,0,0, ProductName DarkRoom, the icon.
It starts wine darkroom-desktop.exe --version → darkroom-desktop 0.12.0, exit 0, 0.1 s.
The installer runs makensis → 105 MB. /S installs the exe and ten models to AppData\Local\Programs\DarkRoom, writes the HKCU uninstall key; the installed exe runs; uninstall.exe /S removes directory and key. Shortcut unverifiable (§6).
It draws a window Not attempted.

What the first draft got wrong, kept in place above with a note rather than rewritten, because the reasoning that produced each mistake is the thing a reader will otherwise repeat:

  1. The --whole-archive -lwinpthread link flag (§2) — breaks the link and was never needed.
  2. No host C compiler in the image (§2) — build scripts are host binaries.
  3. Debian bookworm's Wine (§2) — lacks bcryptprimitives.dll, which rustc's std imports.
  4. A cfg(windows) on the winresource build-dependency (§3.2 item 6) — evaluated against the host, so the crate was silently absent from the cross-build.

And two things it did not know to say: NSIS's default stub is 32-bit (§5.5), and CreateShortcut cannot be verified headless (§6).

Closed since, same day: all four §3.2 items (each says how), a LICENSE at the repository root so the installer has its licence page, and §7's CI leg — windows-image.yml and the windows job, every step of which was run by hand in the same container first. The Windows target is also linted now, with cargo clippy --target x86_64-pc-windows-gnu -- -D warnings in that job, which is the only place the cfg(windows) branches are ever compiled by CI.

What the first real Windows run has to check, in order, because Wine cannot: that a Vulkan device opens on a real driver and a render completes; that fonts are found; that Credential Manager round-trips a sign-in and the browser opens for Login Flow v2; that the Start Menu shortcut exists; and that text is sharp on a scaled display (§3.3). The release notes for the first build should say which of these were checked and on what machine.