diff --git a/OS/.gitkeep b/OS/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/OS/.gitkeep
diff --git a/OS/BACKUP/Readme.md b/OS/BACKUP/Readme.md
new file mode 100644
index 0000000..4578fef
--- /dev/null
+++ b/OS/BACKUP/Readme.md
@@ -0,0 +1,243 @@
+# Restoring Windows on ASUS T101HA
+
+Complete guide to reverting from Xubuntu back to the original Windows installation.
+
+---
+
+## What you have
+
+| Item | Location | Purpose |
+|---|---|---|
+| `t101ha.img.zst` | PC: `E:\git\02 cSharp using mono\main\EARS-LINUX\OS\BACKUP\`
USB: BACKUP partition | Full byte-exact disk image, 17GB compressed |
+| `parts.txt` | Same locations | Partition table reference |
+| `window-key.txt` | Same locations | Windows product key |
+
+**Verified:** SHA256 `CBD4348ACB6777D5110D00F15FD67A6CBABE96152B628E503A8F2F6127E4EB63` (both copies identical)
+
+**Image expands to:** 62,537,072,640 bytes — exact match to device size
+
+---
+
+## Device reference
+
+- **Internal eMMC:** `/dev/mmcblk1` — ~58GiB (62,537,072,640 bytes)
+- **Firmware:** 64-bit UEFI (confirmed via `fw_platform_size`)
+- **Original layout:** ESP, Microsoft reserved, Windows (`p3`), recovery (`p4`)
+
+> **WARNING:** Verify the device with `lsblk` every time. Device names can shift between boots.
+
+---
+
+## Method 1 — Full image restore (recommended)
+
+Restores the exact original system: Windows activated, ASUS recovery partition, everything.
+
+### Preparation
+
+If the image lives on the PC, copy it back to the USB stick's BACKUP partition first. Restoring over a network is not recommended.
+
+### Steps
+
+**1. Boot the Ventoy live session**
+
+Power off fully. Hold F2, tap power, keep holding. Confirm Secure Boot is disabled, boot from USB, select the Xubuntu ISO, choose "Try Xubuntu".
+
+**2. Identify the devices**
+
+```bash
+lsblk -o NAME,SIZE,FSTYPE,LABEL,MOUNTPOINT
+```
+
+Confirm:
+- eMMC = the **~58GiB** device (`mmcblk1`)
+- USB = the **~115GiB** device with Ventoy/VTOYEFI/BACKUP partitions
+
+**3. Mount the backup**
+
+```bash
+sudo mkdir -p /mnt/backup
+sudo mount /dev/sda3 /mnt/backup
+ls -lh /mnt/backup
+```
+
+Adjust `sda3` to match the BACKUP partition from step 2.
+
+**4. Verify the image before writing**
+
+```bash
+zstd -t /mnt/backup/t101ha.img.zst
+```
+
+Must complete without error. If it fails, use the PC copy instead — do not proceed with a corrupt image.
+
+**5. Restore**
+
+```bash
+sudo blockdev --getsize64 /dev/mmcblk1
+```
+
+Confirm this prints `62537072640`. Then:
+
+```bash
+zstd -dc /mnt/backup/t101ha.img.zst | sudo dd of=/dev/mmcblk1 bs=4M status=progress
+sync
+```
+
+> **WARNING:** `of=` is the destructive direction. Everything on that device is erased. Read the command twice before pressing Enter.
+
+Takes 1–2 hours. Do not interrupt it, and do not run anything else — 2GB RAM.
+
+**6. Finish**
+
+```bash
+sync
+sudo umount /mnt/backup
+sudo poweroff
+```
+
+Remove the USB stick. Power on. Windows should boot, activated, exactly as it was.
+
+### With a progress bar
+
+```bash
+sudo apt install -y pv
+sudo sh -c 'zstd -dc /mnt/backup/t101ha.img.zst | pv -s 62537072640 | dd of=/dev/mmcblk1 bs=4M'
+```
+
+---
+
+## Method 2 — Recover files only (non-destructive)
+
+To extract files from the Windows image without wiping Xubuntu. Requires ~58GB free space.
+
+```bash
+zstd -d /path/to/t101ha.img.zst -o /tmp/full.img
+sudo losetup -fP --show /tmp/full.img
+```
+
+Note the loop device it prints (e.g. `/dev/loop0`), then:
+
+```bash
+sudo mkdir -p /mnt/win
+sudo mount -o ro /dev/loop0p3 /mnt/win
+```
+
+Windows lives on partition 3. Browse `/mnt/win`, copy what you need.
+
+Cleanup:
+
+```bash
+sudo umount /mnt/win
+sudo losetup -d /dev/loop0
+rm /tmp/full.img
+```
+
+If mount fails with a BitLocker error, the partition is encrypted — you'll need the recovery key from https://account.microsoft.com/devices/recoverykey
+
+---
+
+## Method 3 — Clean Windows install (image lost)
+
+Last resort. The product key is stored in firmware (ACPI MSDM table), so a clean install self-activates without entering anything.
+
+1. Download the Windows 10 ISO from Microsoft on the PC
+2. Write it to USB with Rufus — **GPT**, **UEFI** target
+3. Boot the tablet from it, install to the internal eMMC
+4. Activation happens automatically from firmware
+
+**Caveats:**
+- The ASUS recovery partition is not restored
+- Cherry Trail drivers may need manual installation from ASUS support
+- **Windows 10 is past end of support (October 2025), and this hardware cannot run Windows 11** — a restored image is a supported-nothing situation either way, but a clean install is strictly worse
+
+---
+
+## Recovering the product key
+
+From Linux, on the running tablet:
+
+```bash
+sudo strings /sys/firmware/acpi/tables/MSDM | tail -1
+```
+
+From the saved file:
+
+```bash
+cat /mnt/backup/window-key.txt; echo
+```
+
+From Windows:
+
+```powershell
+(Get-CimInstance SoftwareLicensingService).OA3xOriginalProductKey
+```
+
+**Note:** viewing `window-key.txt` in Notepad may show CJK garbage — it's an encoding guess, not corruption. Read it on Linux with `cat`, or open it in VS Code and set encoding to UTF-8.
+
+---
+
+## Verifying image integrity
+
+Quick check (structural):
+
+```bash
+zstd -t t101ha.img.zst
+```
+
+Full check (byte count vs device):
+
+```bash
+DEV=$(sudo blockdev --getsize64 /dev/mmcblk1)
+IMG=$(zstd -dc /path/to/t101ha.img.zst | wc -c)
+echo "device: $DEV"
+echo "image: $IMG"
+test "$DEV" = "$IMG" && echo MATCH || echo MISMATCH
+```
+
+Compare copies (PowerShell):
+
+```powershell
+Get-FileHash 'path\to\t101ha.img.zst' -Algorithm SHA256
+```
+
+> **WARNING:** `[ "$DEV" = "$IMG" ]` requires spaces inside the brackets. Without them the shell reports "command not found" and prints MISMATCH regardless of the actual values. Use `test` to avoid this.
+
+---
+
+## Troubleshooting
+
+**Restore finishes but won't boot**
+
+Check Secure Boot in BIOS — re-enable it, since the original Windows install expects it. Confirm the boot order lists Windows Boot Manager.
+
+**`dd` reports "No space left on device"**
+
+Wrong target device. Verify with `lsblk` that you're writing to the ~58GiB eMMC.
+
+**Image fails `zstd -t`**
+
+Use the other copy. If both fail, fall back to Method 3.
+
+**Progress appears frozen**
+
+Check it's actually stalled before interrupting:
+
+```bash
+ps -eo pid,stat,cmd | grep -v grep | grep -E 'dd|zstd'
+sudo dmesg | tail -30
+```
+
+STAT `D` plus `mmc` errors in dmesg indicates bad sectors. STAT `R`/`S` with a clean dmesg means it's just slow — eMMC on this tablet throttles when warm.
+
+**"read kernel buffer failed"**
+
+`dmesg` needs root here: `sudo dmesg`
+
+---
+
+## Notes
+
+- Keep both copies of the image. The USB stick is a single point of failure.
+- Restoring reverts to the disk state as of **27 July 2026**. Nothing created in Xubuntu survives.
+- Do a full `zstd -t` on whichever copy you plan to use *before* wiping anything.
+- Original image created with: `sudo zstd -1 -T0 -f -o /mnt/backup/t101ha.img.zst /dev/mmcblk1`
\ No newline at end of file
diff --git a/OS/Readme.md b/OS/Readme.md
new file mode 100644
index 0000000..c9bb2f5
--- /dev/null
+++ b/OS/Readme.md
@@ -0,0 +1,746 @@
+# Xubuntu Setup — Asus T101HA (`ears-tablet`)
+
+Working notes for setting up an Asus T101HA (Atom x5-Z8350, 2 GB RAM / ~1.8 GB
+usable, eMMC) as a Mono + GTK#3 development and runtime machine.
+
+User: `ears` · Hostname: `ears-tablet`
+
+---
+
+## Contents
+
+- [0. Recommended baseline](#0-recommended-baseline)
+- [1. Installing Xubuntu](#1-installing-xubuntu)
+ - [1.1 Check firmware bitness first](#11-check-firmware-bitness-first--this-determines-everything)
+ - [1.2 Build the USB](#12-build-the-usb)
+ - [1.3 BIOS](#13-bios)
+ - [1.4 Install](#14-install)
+ - [1.5 Post-install GRUB fix (IA32 only)](#15-post-install-grub-fix-ia32-machines-only)
+ - [1.6 Expect to fix afterwards](#16-expect-to-fix-afterwards)
+- [2. First boot / recovery mode](#2-first-boot--recovery-mode)
+- [3. Low-RAM tuning](#3-low-ram-tuning)
+ - [Option A — `zram-tools`](#option-a--zram-tools-simple)
+ - [Option B — `systemd-zram-generator`](#option-b--systemd-zram-generator-current-preferred-on-2404)
+ - [Swappiness](#swappiness)
+- [4. apt: repair and sources](#4-apt-repair-and-sources)
+ - [4.1 The modern layout](#41-the-modern-layout)
+ - [4.2 Correct stock sources](#42-correct-stock-sources-2510-example--substitute-your-codename)
+ - [4.3 Which host? archive vs old-releases](#43-which-host-archive-vs-old-releases)
+ - [4.4 Check for genuinely broken packages](#44-check-for-genuinely-broken-packages)
+ - [4.5 Don't do these](#45-dont-do-these)
+ - [4.6 `full-upgrade` checklist](#46-full-upgrade-checklist)
+ - [4.7 Reboot needed?](#47-reboot-needed)
+- [5. Mono + GTK#3](#5-mono--gtk3)
+ - [Install](#install)
+ - [Optional](#optional)
+ - [Verify](#verify)
+ - [Smoke test](#smoke-test)
+ - [Notes vs. the WSL guide](#notes-vs-the-wsl-guide)
+- [6. Display rotation + touchscreen](#6-display-rotation--touchscreen)
+ - [Identify devices](#identify-devices)
+ - [Matrices](#matrices)
+ - [Persistence script](#persistence-script)
+ - [Login screen (LightDM)](#login-screen-lightdm--separate-from-your-session)
+ - [Better: rotate before the session starts](#better-rotate-before-the-session-starts)
+ - [Wallpaper breaks after rotating](#wallpaper-breaks-after-rotating)
+ - [Auto-rotation (optional)](#auto-rotation-optional)
+- [7. Audio](#7-audio)
+ - [Install and test](#install-and-test)
+ - [Choosing the right card](#choosing-the-right-card)
+ - [Renumbering at the kernel level](#renumbering-at-the-kernel-level)
+ - [Caveats](#caveats)
+- [8. Running a script at boot](#8-running-a-script-at-boot)
+- [9. File transfer to/from Windows](#9-file-transfer-tofrom-windows)
+- [10. Quick reference](#10-quick-reference)
+
+---
+
+## 0. Recommended baseline
+
+**Target: Xubuntu 24.04 LTS (Minimal ISO).**
+
+Reasons, from the apt/Mono investigation:
+- The machine was found running **Ubuntu 25.10 "questing"**, which hit end of
+ life on **9 July 2026**. No further updates will ever be published for it.
+- `gtk-sharp3` is **not packaged on 25.10**, and upgrading to 26.04 would not
+ bring it back.
+- `gtk-sharp3` **is** packaged on 24.04 (noble), supported to 2029.
+- Xfce on 24.04 idles around 500–600 MB, which matters at 1.8 GB RAM.
+
+Mono + `mcs` + GTK# is the correct toolchain for this hardware — the .NET SDK
+(~800 MB) and Avalonia are far heavier.
+
+Before reinstalling, back up: `*.cs`, `*.glade`, SSH keys, browser profile.
+
+```bash
+lsblk # is /home a separate partition?
+df -h /
+free -h
+mkdir -p ~/backup && cp ~/*.cs ~/*.glade ~/backup/
+```
+
+If `/home` is its own partition, it can be preserved during install (assign
+`/home` **without** format). Use the same username to keep permissions clean.
+
+---
+
+## 1. Installing Xubuntu
+
+### 1.1 Check firmware bitness first — this determines everything
+
+In Windows: **Settings → System → About → System type**
+
+- *32-bit operating system, x64-based processor* → **IA32 UEFI**, needs the
+ bootia32 workaround below.
+- *64-bit* → skip the GRUB steps.
+
+### 1.2 Build the USB
+
+Ventoy includes IA32 UEFI support, so try it first. If it won't boot, fall back
+to Rufus and copy a 32-bit `bootia32.efi` (GRUB) into `/EFI/BOOT` on the stick.
+
+From Linux, writing directly:
+
+```bash
+sudo dd if=xubuntu-24.04-minimal-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync
+```
+
+> Verify the target with `lsblk` first. `dd` to the wrong disk destroys it
+> silently.
+
+Verify the ISO: `sha256sum` against the checksum on the download page.
+
+### 1.3 BIOS
+
+- F2 at power-on
+- **Disable Secure Boot** (the ia32 GRUB is unsigned)
+- Boot menu is usually F12 / Esc
+
+### 1.4 Install
+
+Choose **Install**, not *Try* — the live session eats RAM you don't have.
+Don't open Firefox or anything else during install. Let it create swap.
+
+### 1.5 Post-install GRUB fix (IA32 machines only)
+
+The installer writes a 64-bit `grubx64.efi` the firmware can't read. Boot the
+live session, chroot in, and:
+
+```bash
+grub-install --target=i386-efi --efi-directory=/boot/efi
+```
+
+This is **not optional** on IA32 firmware.
+
+### 1.6 Expect to fix afterwards
+
+| Component | Notes |
+|---|---|
+| Wi-Fi | RTL8723BS SDIO — finicky |
+| Audio | ESS ES8316 codec, needs UCM configs |
+| Touchscreen | Works, but rotation needs manual matrix (§6) |
+| Auto-rotation | Needs `iio-sensor-proxy` + custom handler |
+
+A recent 6.x kernel fixes most of these.
+
+---
+
+## 2. First boot / recovery mode
+
+If the user account lacks admin rights:
+
+```bash
+usermod -aG sudo ears
+groups ears # confirm 'sudo' appears
+hostnamectl set-hostname ears-tablet
+localectl set-locale LANG=en_US.UTF-8 # optional, cosmetic
+exit # then 'resume'
+```
+
+`usermod -aG sudo` is the one that isn't optional — without it you can log in
+but can't run any admin command.
+
+---
+
+## 3. Low-RAM tuning
+
+### Option A — `zram-tools` (simple)
+
+```bash
+sudo apt install zram-tools
+```
+
+Edit `/etc/default/zramswap`:
+```
+PERCENT=60
+ALGO=zstd
+```
+
+### Option B — `systemd-zram-generator` (current, preferred on 24.04+)
+
+```bash
+sudo apt install systemd-zram-generator
+
+sudo tee /etc/systemd/zram-generator.conf > /dev/null <<'EOF'
+[zram0]
+zram-size = min(ram / 2, 4096)
+compression-algorithm = zstd
+swap-priority = 100
+EOF
+
+sudo systemctl daemon-reload
+sudo systemctl start systemd-zram-setup@zram0.service
+swapon --show # expect /dev/zram0
+zramctl
+```
+
+The package does nothing without a config file containing at least one section.
+
+### Swappiness
+
+Swapping to zram is cheap, so raise it so the kernel actually uses it:
+
+```bash
+echo 'vm.swappiness=100' | sudo tee /etc/sysctl.d/99-zram.conf
+```
+
+Reboot. Verdict: worth it at 2–4 GB, marginal at 8 GB, skip above 16 GB.
+zram compresses **RAM** — it does nothing for low disk space.
+
+---
+
+## 4. apt: repair and sources
+
+### 4.1 The modern layout
+
+Since 24.04, archive config lives in **`/etc/apt/sources.list.d/ubuntu.sources`**
+(deb822 format). `/etc/apt/sources.list` being empty is normal, not a symptom.
+Most apt advice online predates this and edits a file that does nothing.
+
+### 4.2 Correct stock sources (25.10 example — substitute your codename)
+
+```bash
+sudo tee /etc/apt/sources.list.d/ubuntu.sources > /dev/null <<'EOF'
+Types: deb
+URIs: http://archive.ubuntu.com/ubuntu/
+Suites: questing questing-updates questing-backports
+Components: main restricted universe multiverse
+Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
+
+Types: deb
+URIs: http://security.ubuntu.com/ubuntu/
+Suites: questing-security
+Components: main restricted universe multiverse
+Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
+EOF
+
+sudo apt update
+```
+
+Do not drop the `Signed-By:` line — without it apt falls back to the legacy
+trusted-keyring and you get confusing "not signed" errors later.
+
+`ubuntu.sources.curtin.orig` is the installer's pristine backup. apt ignores
+`.orig` files. Useful as a reference, don't copy blindly.
+
+### 4.3 Which host? archive vs old-releases
+
+An EOL release only moves to `old-releases.ubuntu.com` when the migration
+actually runs — which can lag the EOL date by weeks. Pointing there too early
+gives four clean 404s. Test before switching:
+
+```bash
+for p in questing questing-updates questing-backports questing-security; do
+ echo -n "$p: "
+ curl -sI "http://old-releases.ubuntu.com/ubuntu/dists/$p/Release" | head -1
+done
+
+curl -sI http://archive.ubuntu.com/ubuntu/dists/questing/Release | head -1
+```
+
+`200` = pocket exists. `404` = drop it or use the other host.
+
+### 4.4 Check for genuinely broken packages
+
+All read-only, cheapest first:
+
+```bash
+sudo dpkg --audit # clean = no output at all
+sudo apt-get check # verifies the dependency tree
+dpkg -l | grep -v '^ii' | grep -v '^rc'
+apt-mark showhold # held packages are silently skipped on upgrade
+```
+
+dpkg status codes (desired state, then actual):
+
+| Code | Meaning |
+|---|---|
+| `ii` | Healthy |
+| `rc` | Removed, config left — harmless |
+| `iU` | Unpacked, never configured |
+| `iF` | Half-configured |
+| `iH` | Half-installed |
+| `iW` / `it` | Waiting on / pending triggers |
+| `*R*` | Reinstall required → `apt install --reinstall ` |
+
+Then repair:
+
+```bash
+sudo dpkg --configure -a
+sudo apt --fix-broken install
+sudo apt full-upgrade
+```
+
+### 4.5 Don't do these
+
+```bash
+# WRONG — killall receives 'sudo', 'rm', paths etc. as process names
+sudo killall apt apt-get dpkg 2>/dev/null sudo rm /var/lib/apt/lists/lock ...
+
+# DANGEROUS — rm -rf also targets files named sudo, apt, clean, update in $PWD
+sudo rm -rf /var/lib/apt/lists/* sudo apt clean sudo apt update
+```
+
+These must be separate lines. Also: killing `dpkg` mid-transaction is what
+*creates* the broken state. Check first with `ps aux | grep -E 'apt|dpkg'`.
+
+Don't remove `universe` — it's an Ubuntu component, not a PPA, and
+`gtk-sharp3` lives there. And never run
+`add-apt-repository --remove ppa:repository-name/ppa` literally; that's a
+placeholder from a copied guide.
+
+### 4.6 `full-upgrade` checklist
+
+- Use `full-upgrade`, not `upgrade` — it permits removals where deps changed
+- Read the summary line; mass removals of desktop/kernel packages = stop
+- Plug the tablet in; power loss mid-`dpkg` recreates the broken state
+- On config prompts, keeping the local version (the default) is safe unless
+ you know you edited it
+- Then `sudo apt autoremove` — but answer `n` if anything with `efi` appears
+- Reboot before `do-release-upgrade` so the newest kernel is running
+
+### 4.7 Reboot needed?
+
+```bash
+[ -f /var/run/reboot-required ] && cat /var/run/reboot-required.pkgs
+sudo needrestart # interactive; shows what's running old libs
+```
+
+Kernel / initramfs / systemd / glibc → reboot. Everything else → restart the
+service.
+
+---
+
+## 5. Mono + GTK#3
+
+### Install
+
+```bash
+sudo add-apt-repository universe
+sudo apt update
+sudo apt install mono-complete gtk-sharp3
+```
+
+| Package | Contents | Size |
+|---|---|---|
+| `mono-runtime` | JIT + `mscorlib` only | ~15 MB |
+| `mono-devel` | Compiler + the BCL you need | ~200–400 MB |
+| `mono-complete` | Runtime + `mcs` + entire class library + F#/VB | ~300–500 MB |
+
+**Avoid `mono-runtime`.** The BCL is split across dozens of
+`libmono-system-*-cil` packages; hello-world runs, but anything touching LINQ,
+XML, HTTP, or `System.IO.Ports` (the COM-port combo box in `homepage.glade`)
+throws `Could not load file or assembly`. On a tight disk, `mono-devel` saves
+~100 MB over `mono-complete` and still covers everything this project needs.
+
+### Optional
+
+```bash
+sudo apt install glade # visual designer — skip on this machine
+sudo apt install libgdiplus # only if using System.Drawing
+sudo apt install libcanberra-gtk3-module # silences a harmless console warning
+```
+
+### Verify
+
+```bash
+mono --version
+mcs --version
+pkg-config --list-all | grep gtk-sharp # want gtk-sharp-3.0
+apt policy gtk-sharp3
+```
+
+### Smoke test
+
+```bash
+cat > gtkcheck.cs <<'EOF'
+using System;
+using Gtk;
+
+class GtkCheck {
+ static void Main() {
+ Application.Init();
+ var win = new Window("Hello GTK#3");
+ win.SetDefaultSize(300, 200);
+ win.DeleteEvent += (o, args) => Application.Quit();
+
+ var button = new Button("Click me");
+ button.Clicked += (o, args) => Console.WriteLine("Clicked!");
+ win.Add(button);
+
+ win.ShowAll();
+ Application.Run();
+ }
+}
+EOF
+
+mcs -pkg:gtk-sharp-3.0 gtkcheck.cs
+mono gtkcheck.exe
+```
+
+Do the throwaway test first — it isolates "is gtk-sharp3 working" from "does my
+app compile."
+
+Then the real thing:
+
+```bash
+mcs -pkg:gtk-sharp-3.0 program.cs MainWindow.cs ArduinoConnection.cs -out:test.exe
+mono test.exe
+```
+
+(`ArduinoConnection.cs` is needed because `MainWindow` calls
+`_arduino.Disconnect()`; omitting it gives `CS0246`.)
+
+### Notes vs. the WSL guide
+
+- No WSLg, no `$DISPLAY` fiddling — Xfce is itself GTK3, so the app picks up the
+ system theme and the window just appears.
+- Only exception: over SSH you need `ssh -X`.
+- Companion files (`-r:` DLLs, `homepage.glade`) must sit next to the `.exe`, or
+ you get `FileNotFoundException`.
+- `homepage.glade` targets GTK 3.24, which 22.04 and 24.04 both ship.
+- `gtk-sharp2` is gone from 24.04 onward — use `gtk-sharp3`.
+
+---
+
+## 6. Display rotation + touchscreen
+
+X11 does **not** rotate touch input when you rotate the display. The touch
+device needs its own coordinate transformation matrix.
+
+### Identify devices
+
+```bash
+xrandr # output name — DSI-1, or None-1 on this unit
+xinput list --name-only # look for Silead / GSL / SIS, NOT 'Touchpad'
+```
+
+> On this tablet the output reported as **`None-1`** (xrandr's fallback when the
+> connector type isn't identified — it can change between kernels) and the
+> touchscreen as **`SIS0457:00 0457:11ED`**.
+
+### Matrices
+
+| Rotation | Matrix |
+|---|---|
+| normal | `1 0 0 0 1 0 0 0 1` |
+| right (90° CW) | `0 1 0 -1 0 1 0 0 1` |
+| left (90° CCW) | `0 -1 1 1 0 0 0 0 1` |
+| inverted (180°) | `-1 0 1 0 -1 1 0 0 1` |
+
+```bash
+xrandr --output None-1 --rotate right
+xinput set-prop "SIS0457:00 0457:11ED" "Coordinate Transformation Matrix" 0 1 0 -1 0 1 0 0 1
+```
+
+### Persistence script
+
+```bash
+nano ~/rotate.sh
+```
+
+```bash
+#!/bin/bash
+sleep 3
+OUT=$(xrandr | awk '/ connected/{print $1; exit}')
+xrandr --output "$OUT" --rotate right
+TS=$(xinput list --name-only | grep -iE 'silead|gsl|touchscreen|SIS' | grep -vi 'touchpad' | head -1)
+[ -n "$TS" ] && xinput set-prop "$TS" "Coordinate Transformation Matrix" 0 1 0 -1 0 1 0 0 1
+xset s off
+xset -dpms
+```
+
+```bash
+chmod +x ~/rotate.sh
+~/rotate.sh # test before automating
+```
+
+Then **Settings → Session and Startup → Application Autostart → Add**, command
+`/home/ears/rotate.sh`.
+
+> **Gotcha:** grepping bare `touch` matches the **trackpad** and rotates the
+> pointer instead. Use `touchscreen` and exclude `touchpad`. To undo:
+> ```bash
+> TP=$(xinput list --name-only | grep -i -m1 touchpad)
+> xinput set-prop "$TP" "Coordinate Transformation Matrix" 1 0 0 0 1 0 0 0 1
+> ```
+> Or just log out — the matrix isn't saved anywhere.
+
+The `sleep 3` matters; the touchscreen isn't registered the instant the session
+starts. Raise to 5 if touch still comes out wrong.
+
+**Try Settings → Display first.** Its rotation dropdown often persists on its
+own, leaving the script to handle only the `xinput` part.
+
+### Login screen (LightDM — separate from your session)
+
+```bash
+sudo nano /etc/lightdm/lightdm.conf
+```
+```ini
+[Seat:*]
+display-setup-script=/usr/bin/xrandr --output None-1 --rotate right
+```
+
+### Better: rotate before the session starts
+
+```bash
+sudo nano /etc/X11/xorg.conf.d/10-monitor.conf
+```
+```
+Section "Monitor"
+ Identifier "DSI-1"
+ Option "Rotate" "right"
+EndSection
+```
+
+Whole machine including the text console — add to `/etc/default/grub`:
+`fbcon=rotate:1` plus `video=DSI-1:panel_orientation=right_side_up`, then
+`sudo update-grub`.
+
+### Wallpaper breaks after rotating
+
+The desktop paints the wallpaper onto a surface sized for the old geometry and
+doesn't repaint after `xrandr`. On Xfce:
+
+```bash
+xfdesktop --reload
+```
+
+Also set the image style to **scaled** — `spanned` and `zoom` misbehave badly
+when the aspect ratio flips. Rotating before the session starts avoids this
+entirely.
+
+### Auto-rotation (optional)
+
+Xfce has no built-in handler. Confirm the sensor works before writing anything:
+
+```bash
+sudo apt install -y iio-sensor-proxy
+monitor-sensor # tilt the tablet; orientation should print
+```
+
+If nothing prints, this unit needs a kernel quirk — not worth chasing.
+
+```bash
+monitor-sensor | while read -r line; do
+ case "$line" in
+ *normal*) R=normal; M="1 0 0 0 1 0 0 0 1" ;;
+ *right-up*) R=right; M="0 1 0 -1 0 1 0 0 1" ;;
+ *left-up*) R=left; M="0 -1 1 1 0 0 0 0 1" ;;
+ *bottom-up*) R=inverted; M="-1 0 1 0 -1 1 0 0 1" ;;
+ *) continue ;;
+ esac
+ xrandr --output DSI-1 --rotate "$R"
+ xinput set-prop "SIS0457:00 0457:11ED" "Coordinate Transformation Matrix" $M
+done
+```
+
+> Most community auto-rotate scripts only call `xrandr` and leave touch behind.
+> If a script doesn't contain `xinput set-prop`, it won't rotate touch.
+
+---
+
+## 7. Audio
+
+### Install and test
+
+```bash
+sudo apt install alsa-utils # this is what provides aplay
+
+aplay --version
+aplay -l # list playback devices
+aplay /usr/share/sounds/alsa/Front_Center.wav
+speaker-test -c 2 -t wav -l 1
+```
+
+PipeWire/PulseAudio layer:
+
+```bash
+pactl info # "Server Name" tells you which
+pactl list short sinks
+paplay /usr/share/sounds/alsa/Front_Center.wav
+```
+
+### Choosing the right card
+
+Neither config file exists by default — create whichever you prefer:
+
+```bash
+nano ~/.asoundrc # per-user; wins over the system file
+# or
+sudo nano /etc/asound.conf # system-wide, incl. root and systemd services
+```
+
+```
+defaults.pcm.card 1
+defaults.ctl.card 1
+```
+
+`ctl` is what `alsamixer` reads, `pcm` is what `aplay` reads — set both. No
+reload needed; it's read per-process.
+
+With `plughw` conversion behaviour as the default:
+
+```
+pcm.!default {
+ type plug
+ slave.pcm "hw:1,0"
+}
+ctl.!default {
+ type hw
+ card 1
+}
+```
+
+Verify — `-v` prints the resolved device:
+
+```bash
+aplay -D default -v /usr/share/sounds/alsa/Front_Center.wav 2>&1 | head -20
+```
+
+### Renumbering at the kernel level
+
+```bash
+cat /proc/asound/modules
+sudo nano /etc/modprobe.d/alsa-card-order.conf
+```
+```
+options snd_soc_sst_bytcr_rt5640 index=0
+options snd_hda_intel index=1
+```
+```bash
+sudo update-initramfs -u && sudo reboot
+```
+
+### Caveats
+
+- **PipeWire ignores all of the above.** `~/.asoundrc` only governs programs
+ talking to ALSA directly (`aplay`, `alsamixer`). For desktop apps use
+ `pactl set-default-sink` or `pavucontrol`.
+- Save your mixer levels or they come back muted: `sudo alsactl store 1`
+- Device busy → use `aplay -D default file.wav`, not `-D hw:0,0`
+- Permission errors → `sudo usermod -aG audio $USER`, then re-login
+- `aplay` handles WAV/AU/RAW only — use `mpg123` or `ffplay` for MP3/FLAC
+
+---
+
+## 8. Running a script at boot
+
+```bash
+sudo nano /usr/local/bin/myscript.sh
+sudo chmod +x /usr/local/bin/myscript.sh
+```
+
+Start with `#!/bin/bash` and use **absolute paths** — there's almost no
+environment at boot.
+
+```bash
+sudo nano /etc/systemd/system/myscript.service
+```
+
+```ini
+[Unit]
+Description=My boot script
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=oneshot
+ExecStart=/usr/local/bin/myscript.sh
+RemainAfterExit=yes
+
+[Install]
+WantedBy=multi-user.target
+```
+
+```bash
+sudo systemctl daemon-reload
+sudo systemctl enable --now myscript.service
+systemctl status myscript.service
+journalctl -u myscript.service
+```
+
+Variations:
+
+- Long-running daemon → `Type=simple`, drop `RemainAfterExit`
+- Before the login screen → `Before=display-manager.service` in `[Unit]`,
+ `WantedBy=display-manager.service` in `[Install]`
+- Quick and dirty → `sudo crontab -e`, then
+ `@reboot /usr/local/bin/myscript.sh` (no logging, no ordering)
+- Drop `After=`/`Wants=` if networking isn't needed — it'll run earlier
+
+> **A root systemd service cannot rotate the display** — no `DISPLAY`, no
+> `XAUTHORITY`. Use the config-level methods in §6 instead. Same for audio: a
+> root service has no audio session, so use `systemctl --user` or target the
+> hardware device directly.
+
+---
+
+## 9. File transfer to/from Windows
+
+**Tailscale is not required.** It creates a private network *across the
+internet* — only needed to reach the tablet from outside your home. For a
+tablet and PC on the same Wi-Fi it's just another background daemon on a
+2 GB machine.
+
+Lightest option — SSH:
+
+```bash
+sudo apt install -y openssh-server
+```
+
+Connect from Windows with WinSCP or `scp` in PowerShell. No configuration
+beyond the above.
+
+Samba is only worth it if you want the tablet's folders to appear in Windows
+Explorer as a normal network drive.
+
+---
+
+## 10. Quick reference
+
+```bash
+# state
+free -h; df -h /; lsblk; swapon --show
+cat /etc/os-release
+. /etc/os-release; echo $VERSION_CODENAME
+
+# apt health
+sudo dpkg --audit; sudo apt-get check; apt-mark showhold
+ls /etc/apt/sources.list.d/
+cat /etc/apt/sources.list.d/ubuntu.sources
+
+# display
+xrandr; xinput list --name-only
+~/rotate.sh; xfdesktop --reload
+
+# audio
+aplay -l; pactl list short sinks
+aplay /usr/share/sounds/alsa/Front_Center.wav
+sudo alsactl store 1
+
+# build
+mcs -pkg:gtk-sharp-3.0 program.cs MainWindow.cs ArduinoConnection.cs -out:test.exe
+mono test.exe
+```
\ No newline at end of file
diff --git a/Readme.md b/Readme.md
index 402d96e..0c777af 100644
--- a/Readme.md
+++ b/Readme.md
@@ -1,20 +1,39 @@
# Mono + GTK# + Glade — WSL Setup Guide
## Table of Contents
-- [1. Prerequisites](#1-prerequisites)
-- [2. Install Mono](#2-install-mono)
-- [3. Install GTK# (GTK# 3.0, for Ubuntu 24.04)](#3-install-gtk-gtk-30-for-ubuntu-2404)
-- [4. Install Glade (visual UI designer)](#4-install-glade-visual-ui-designer)
-- [5. Optional: libgdiplus (only if using System.Drawing)](#5-optional-libgdiplus-only-if-using-systemdrawing)
-- [6. Compiling & Running](#6-compiling--running)
-- [7. Minimal Working Examples](#7-minimal-working-examples)
-- [8. Custom Output Names, Targets & Exporting DLLs](#8-custom-output-names-targets--exporting-dlls)
-- [9. Common Errors & Fixes](#9-common-errors--fixes)
-- [10. Notes on Alternatives](#10-notes-on-alternatives)
+- [Mono](#mono)
+ - [1. Prerequisites](#1-prerequisites)
+ - [2. Install Mono](#2-install-mono)
+ - [3. Install GTK# (GTK# 3.0, for Ubuntu 24.04)](#3-install-gtk-gtk-30-for-ubuntu-2404)
+ - [4. Install Glade (visual UI designer)](#4-install-glade-visual-ui-designer)
+ - [5. Optional: libgdiplus (only if using System.Drawing)](#5-optional-libgdiplus-only-if-using-systemdrawing)
+ - [6. Compiling & Running](#6-compiling--running)
+ - [7. Minimal Working Examples](#7-minimal-working-examples)
+ - [8. Custom Output Names, Targets & Exporting DLLs](#8-custom-output-names-targets--exporting-dlls)
+ - [9. Common Errors & Fixes](#9-common-errors--fixes)
+ - [10. Notes on Alternatives](#10-notes-on-alternatives)
+- [OS](#os)
+ - [Workflow](#workflow)
+ - [OS images checked](#os-images-checked)
+ - [OS images to check](#os-images-to-check)
+- [EARS Application](#ears-application)
+ - [11. Project Layout](#11-project-layout)
+ - [12. Single-Window Notebook Architecture](#12-single-window-notebook-architecture)
+ - [13. Glade Rules That Bite](#13-glade-rules-that-bite)
+ - [14. Generating Widgets From Code](#14-generating-widgets-from-code)
+ - [15. Loading cases.csv](#15-loading-casescsv)
+ - [16. File Paths — AppFile()](#16-file-paths--appfile)
+ - [17. Audio Playback](#17-audio-playback)
+ - [18. Build & Run](#18-build--run)
+ - [19. Debugging Autoconnect Failures](#19-debugging-autoconnect-failures)
+ - [20. Windows Deployment Differences](#20-windows-deployment-differences)
+
---
-## 1. Prerequisites
+## Mono
+
+### 1. Prerequisites
- Windows 11 (or updated Windows 10) with WSL2 and **WSLg** enabled for GUI support
- Ubuntu 24.04 (noble) or similar WSL distro
@@ -26,7 +45,7 @@
---
-## 2. Install Mono
+### 2. Install Mono
```bash
sudo apt update
@@ -41,7 +60,7 @@
---
-## 3. Install GTK# (GTK# 3.0, for Ubuntu 24.04)
+### 3. Install GTK# (GTK# 3.0, for Ubuntu 24.04)
> Note: `gtk-sharp2` is **not available** on Ubuntu 24.04 (noble). Use `gtk-sharp3` instead.
@@ -59,7 +78,7 @@
gtk-sharp-3.0 Gtk - Gtk
---
-## 4. Install Glade (visual UI designer)
+### 4. Install Glade (visual UI designer)
```bash
sudo apt install glade
@@ -73,7 +92,7 @@
---
-## 5. Optional: libgdiplus (only if using System.Drawing)
+### 5. Optional: libgdiplus (only if using System.Drawing)
Only needed if your code uses `System.Drawing.Bitmap`, `Graphics`, etc. (not required for plain GTK#/Glade apps):
@@ -83,21 +102,21 @@
---
-## 6. Compiling & Running
+### 6. Compiling & Running
-### Plain console C# program
+#### Plain console C# program
```bash
mcs myprogram.cs
mono myprogram.exe
```
-### GTK# 3.0 program (no Glade)
+#### GTK# 3.0 program (no Glade)
```bash
mcs -pkg:gtk-sharp-3.0 myapp.cs
mono myapp.exe
```
-### GTK# 3.0 program using a Glade file
+#### GTK# 3.0 program using a Glade file
```bash
mcs -pkg:gtk-sharp-3.0 -pkg:glade-sharp-3.0 myapp.cs
mono myapp.exe
@@ -106,9 +125,9 @@
---
-## 7. Minimal Working Examples
+### 7. Minimal Working Examples
-### Hello World (console)
+#### Hello World (console)
```csharp
using System;
@@ -123,7 +142,7 @@
mono hello.exe
```
-### Hello World (GTK# window, no Glade)
+#### Hello World (GTK# window, no Glade)
```csharp
using System;
using Gtk;
@@ -149,7 +168,7 @@
mono gtkcheck.exe
```
-### Loading a UI built in Glade
+#### Loading a UI built in Glade
```csharp
using System;
using Gtk;
@@ -175,11 +194,11 @@
---
-## 8. Custom Output Names, Targets & Exporting DLLs
+### 8. Custom Output Names, Targets & Exporting DLLs
By default, `mcs file.cs` names the output after the source file (`file.exe`). You can control this with `-out:` and change what kind of binary is produced with `-target:`.
-### 8.1 Custom output name
+#### 8.1 Custom output name
```bash
mcs -pkg:gtk-sharp-3.0 Program.cs -out:MyCustomApp.exe
```
@@ -188,7 +207,7 @@
mono MyCustomApp.exe
```
-### 8.2 `-target` options
+#### 8.2 `-target` options
| Target | Produces | Notes |
|---|---|---|
| `exe` (default) | Console executable | Shows a console window when run on Windows |
@@ -202,7 +221,7 @@
```
This produces `programwin.exe`, which on Windows will run without popping up a console window alongside your GTK window.
-### 8.3 Exporting a DLL
+#### 8.3 Exporting a DLL
If you want to package reusable code (helper classes, business logic, etc.) as a library instead of a standalone app:
@@ -213,7 +232,7 @@
- No `Main()` method is required in a `library` target (though it's fine if one class in your project has one — it just won't be used as an entry point for the DLL itself).
- This creates `MyLibrary.dll`, a Mono/.NET assembly that other C# programs can reference.
-### 8.4 Using a DLL in another program
+#### 8.4 Using a DLL in another program
Suppose `MyLibrary.dll` contains:
```csharp
@@ -253,7 +272,7 @@
mono ConsumerApp.exe
```
-### 8.5 Combining `-target:library` with GTK# packages
+#### 8.5 Combining `-target:library` with GTK# packages
If your DLL itself uses GTK# types (e.g., a shared custom widget), include the package flag when building the library too:
```bash
mcs -target:library -pkg:gtk-sharp-3.0 MyGtkWidgets.cs -out:MyGtkWidgets.dll
@@ -263,7 +282,7 @@
mcs -pkg:gtk-sharp-3.0 ConsumerApp.cs -r:MyGtkWidgets.dll -out:ConsumerApp.exe
```
-### 8.6 Quick reference
+#### 8.6 Quick reference
```bash
# Console exe, custom name
mcs Program.cs -out:myapp.exe
@@ -278,7 +297,7 @@
mcs Consumer.cs -r:MyLibrary.dll -out:Consumer.exe
```
-### 8.7 Running the compiled .exe on Windows (outside WSL)
+#### 8.7 Running the compiled .exe on Windows (outside WSL)
Compiling in WSL produces a `.exe` that targets the .NET/Mono runtime — it is **not** a native Windows binary, and it will not run on Windows by itself. Two things are needed:
@@ -308,7 +327,7 @@
---
-## 9. Common Errors & Fixes
+### 9. Common Errors & Fixes
| Error | Cause | Fix |
|---|---|---|
@@ -322,7 +341,7 @@
---
-## 10. Notes on Alternatives
+### 10. Notes on Alternatives
GTK# is a legacy, lightly-maintained binding. For new projects, consider:
- **Avalonia UI** — modern, XAML-based, cross-platform, actively maintained
@@ -353,4 +372,512 @@
to run note:
same for all cases
- mono NAME_OF_THE_FILE.exe
\ No newline at end of file
+ mono NAME_OF_THE_FILE.exe
+
+## OS
+
+To test the application/setup across different Linux distributions, I used **Ventoy** to create a multiboot USB drive. Ventoy lets you copy multiple ISO files onto a single USB stick and choose which one to boot at startup, without needing to reformat or re-flash the drive for each OS.
+
+### Workflow
+1. Install Ventoy on the target USB drive.
+2. Copy the desired `.iso` files directly onto the Ventoy partition (no extraction needed).
+3. Boot from the USB, select the ISO from the Ventoy boot menu, and test the OS live or install it.
+
+### OS images checked
+- Fedora-Workstation-Live-44-1.7.x86_64
+- lubuntu-26.04-desktop-amd64
+- xubuntu-25.10-desktop-amd64
+
+### OS images to check
+- antiX-26_x64-full
+- xubuntu-26.04-minimal-amd64
+
+
+---
+
+# EARS Application
+
+Notes covering the GTK# port of the auscultation trainer: dynamic UI
+generation from CSV, the notebook page structure, and the failure modes
+that cost the most time.
+
+---
+
+## 11. Project Layout
+
+Source lives in `program/`, build output in `program/bin/linux/`.
+Runtime assets must sit **next to the .exe**, not next to the source.
+
+```
+program/
+├── Program.cs
+├── MainWindow.cs root window + splash page
+├── SettingWindow.cs settings page controller
+├── SoundWindow.cs sound page controller
+├── CaseDefinition.cs CSV row model + loader
+├── WavePlayer.cs cross-platform wav playback
+├── ArduinoConnection.cs
+├── Language.cs
+├── homepageallv3.glade
+├── cases.csv
+├── map/
+├── sound/
+└── bin/linux/ ← everything above (minus .cs) copied here
+ ├── program.exe
+ ├── homepageallv3.glade
+ ├── cases.csv
+ ├── map/
+ └── sound/
+```
+
+---
+
+## 12. Single-Window Notebook Architecture
+
+One `GtkWindow` (`Root`) holds one `GtkNotebook` (`RootNoteBook`) with
+tabs hidden. Each "screen" is a notebook page; navigation is just
+`RootNoteBook.CurrentPage = N`. No second window is ever created.
+
+| Page | Index | Root widget | Controller |
+|---|---|---|---|
+| Splash | 0 | `SplashGrid` | `MainWindow` |
+| Settings | 1 | `SettingGird` | `SettingsWindow` |
+| Sound | 2 | `SoundBox` | `SoundWindow` |
+
+`SettingsWindow` and `SoundWindow` are **not** `Gtk.Window` subclasses
+despite the names — they're plain controller classes that receive the
+shared `Builder` and bind their own widgets:
+
+```csharp
+public class SoundWindow
+{
+ [UI] private Label ConditionNameLabel = null;
+ [UI] private Grid SoundButtonGrid = null;
+ [UI] private Button SoundBackButton = null;
+
+ public event EventHandler BackRequested;
+
+ public SoundWindow(Builder builder)
+ {
+ builder.Autoconnect(this);
+ ...
+ }
+}
+```
+
+Wired up in `MainWindow`'s private constructor:
+
+```csharp
+_soundWindow = new SoundWindow(builder);
+_soundWindow.BackRequested += (s, e) => RootNoteBook.CurrentPage = PageSplash;
+Option1Button.Clicked += OnNormalSessionClicked;
+```
+
+Controllers never touch the notebook directly — they raise
+`BackRequested` and let `MainWindow` decide. Keeps navigation in one place.
+
+**All three controllers share one `Builder`.** Each `Autoconnect` call
+binds only the ids matching that class's `[UI]` fields, which is exactly
+why ids must be unique across the entire file (§13).
+
+---
+
+## 13. Glade Rules That Bite
+
+### IDs must be unique file-wide and valid C# identifiers
+
+`Autoconnect` maps glade ids to field names by string match across the
+**whole** builder, not per page. Two widgets sharing an id bind
+unpredictably.
+
+| Broken | Why | Fixed |
+|---|---|---|
+| `Condition Name` | Space — never matches a field name | `ConditionNameLabel` |
+| `BackButto` | Typo | `SoundBackButton` |
+| `BackButton` on two pages | Duplicate across pages | `SettingBackButton` + `SoundBackButton` |
+
+A mismatch **fails silently** — the field stays `null` and you get a
+`NullReferenceException` later, often several clicks away from the cause.
+See §19 for the guard that catches this at startup.
+
+Verify before running:
+
+```bash
+grep -o 'id="[^"]*"' homepageallv3.glade | sort | uniq -d
+```
+
+Any output is a duplicate id.
+
+### Placeholders are design-time only
+
+The hatched empty cells Glade shows in a `GtkGrid` save as
+`` and are **ignored at load time**. A button placed at
+`left-attach=2` in a designer grid with two empty columns to its left
+ends up at column 0 in the running app — this is the classic
+"button jumps to the left" bug.
+
+Never use placeholders for spacing or alignment. Use `halign` + `hexpand`.
+
+### Right-aligning a button
+
+Both properties are required:
+
+```xml
+end
+True
+```
+
+`hexpand` makes the cell consume the full row width; `halign=end` parks
+the button at the right edge of that cell. `halign` alone does nothing
+when the cell is only as wide as the button.
+
+In Glade: **Common** tab → *Horizontal Alignment* = `End`,
+*Expand → Horizontal* = checked.
+
+### Scrolling a grid needs a Viewport
+
+`GtkGrid` doesn't implement `GtkScrollable`. Dropping one into a
+`GtkScrolledWindow` requires an intermediate `GtkViewport` — Glade
+inserts it automatically. Don't delete it.
+
+```
+SoundScroller GtkScrolledWindow hexpand + vexpand, packing expand=True
+└─ GtkViewport (auto-added, required)
+ └─ SoundButtonGrid GtkGrid empty, column-homogeneous=True
+```
+
+`GtkFlowBox` *is* scrollable and wraps children automatically, but the
+`gtk-sharp3` binding on Ubuntu is 2.99.x and may not expose it. Check
+before relying on it:
+
+```bash
+monop -r:/usr/lib/cli/gtk-sharp-3.0/gtk-sharp.dll Gtk.FlowBox
+```
+
+### Expand appears in two tabs
+
+For a widget inside a `GtkBox`, **Common → Expand** sets the widget's own
+`hexpand`/`vexpand`, while **Packing → Expand** sets the box child
+property. They are different things and both usually need setting.
+
+---
+
+## 14. Generating Widgets From Code
+
+Leave the container **empty** in Glade and fill it at runtime. Three
+rules, all of which produce silent failures when broken:
+
+```csharp
+private void BuildButtons(IEnumerable labels, Action onPick)
+{
+ // 1. Remove AND destroy — Remove alone leaks the widget
+ foreach (var child in SoundButtonGrid.Children)
+ {
+ SoundButtonGrid.Remove(child);
+ child.Destroy();
+ }
+
+ int i = 0;
+ foreach (string text in labels)
+ {
+ // 2. Capture the loop variable — otherwise every handler
+ // sees the final value
+ string captured = text;
+
+ var btn = new Button(captured);
+ btn.Hexpand = true;
+ btn.Clicked += (s, e) => onPick(captured);
+ SoundButtonGrid.Attach(btn, i % Columns, i / Columns, 1, 1);
+ i++;
+ }
+
+ // 3. Widgets created in code start HIDDEN. Without this the grid
+ // stays blank with no error of any kind.
+ SoundButtonGrid.ShowAll();
+}
+```
+
+`ShowAll()` on the container is the single most common cause of
+"my buttons didn't appear" in GTK#.
+
+### Drill-down navigation
+
+All three modes (type → condition → play) use one grid and one render
+method. State is a `List` path; Back pops one level:
+
+```csharp
+private readonly List _path = new List();
+
+private void Render()
+{
+ var matches = _allCases.Where(MatchesPath).ToList();
+
+ var options = matches
+ .Where(c => c.TreePath.Length > _path.Count)
+ .Select(c => c.TreePath[_path.Count])
+ .Distinct()
+ .ToList();
+
+ ConditionNameLabel.Text = _path.Count == 0
+ ? "種別を選択"
+ : string.Join(" : ", _path);
+
+ BuildButtons(options, picked => { _path.Add(picked); /* leaf? play : Render(); */ });
+}
+
+private void OnBackClicked(object sender, EventArgs e)
+{
+ if (_path.Count > 0) { _path.RemoveAt(_path.Count - 1); Render(); }
+ else { WavePlayer.Stop(); BackRequested(this, EventArgs.Empty); }
+}
+```
+
+Tree depth varies per row (`Tree_Level3` is often empty), so
+`CaseDefinition.TreePath` returns only the non-empty levels and the
+drill-down adapts automatically.
+
+---
+
+## 15. Loading cases.csv
+
+`CaseDefinition.LoadCasesFromCsv(path)` returns `List`.
+Column → property mapping:
+
+| CSV column | Property |
+|---|---|
+| `Number` | `Number` |
+| `Type` | `Type` (心音 / 呼吸音) — also drives `IsHeart` |
+| `Category` | `CategoryJp` |
+| `Subcategory` | `SubcategoryJp` |
+| `Location` | `LocationJp` |
+| `Tree_Level1..3` | `TreeLevel1..3` |
+| `Image_File` | `MapFront` |
+| `Sound_File` | `SoundPath` |
+| `Image_Right` / `Image_Left` / `Image_Back` | `MapRight` / `MapLeft` / `MapBack` |
+
+Four things the loader must get right:
+
+**Encoding.** The file contains Japanese. Read with `Encoding.UTF8` and
+BOM detection — the default ANSI codepage produces mojibake that only
+shows up on the rendered buttons.
+
+**Delimiter.** The file is TAB separated. Auto-detect so a re-export
+from Excel as comma-separated doesn't break it:
+
+```csharp
+char sep = lines[0].Contains("\t") ? '\t' : ',';
+```
+
+**Header mapping.** Build `name → index` from row 0 rather than
+hardcoding positions, so adding or reordering columns is safe.
+
+**InvariantCulture on every numeric parse.** Same failure class as the
+CSS scaling bug in §"scaling" — under `ja_JP`/`de_DE` the
+culture-sensitive default silently misparses:
+
+```csharp
+int.TryParse(get(f, "Number"), NumberStyles.Integer,
+ CultureInfo.InvariantCulture, out number)
+```
+
+The loader prints its result on every run:
+
+```
+Loaded 98 cases from /path/to/bin/linux/cases.csv
+```
+
+`Loaded 0 cases` points at the path or the delimiter, not the UI.
+
+---
+
+## 16. File Paths — AppFile()
+
+**Never use bare relative paths.** `Path.GetFullPath("cases.csv")`
+resolves against the *working directory*, so the app works when launched
+from `program/` and silently loads nothing from anywhere else.
+
+```csharp
+public static string AppFile(string relative)
+{
+ string dir = Path.GetDirectoryName(
+ System.Reflection.Assembly.GetExecutingAssembly().Location);
+ return Path.Combine(dir, relative);
+}
+```
+
+Use it for **everything**: the glade file, the CSV, maps, sounds.
+
+```csharp
+builder.AddFromFile(SoundWindow.AppFile("homepageallv3.glade"));
+```
+
+Mixing the two conventions is how you end up editing
+`program/homepageallv3.glade` while the app reads
+`bin/linux/homepageallv3.glade` — every fix appears to do nothing.
+
+**Linux is case-sensitive.** `sound/snd200.wav` ≠ `sound/SND200.wav`,
+which was fine on Windows and isn't now. Audit the CSV against the disk:
+
+```bash
+cut -f10 cases.csv | tail -n +2 | while read f; do
+ [ -n "$f" ] && [ -f "$f" ] || echo "MISSING: $f"
+done
+```
+
+---
+
+## 17. Audio Playback
+
+XAudio2/SharpDX from the WinForms build does **not** load under Mono on
+Linux. `WavePlayer` shells out instead:
+
+| Platform | Mechanism |
+|---|---|
+| Linux / WSL | `paplay`, falling back to `aplay` |
+| Windows | `System.Media.SoundPlayer` |
+
+Install both helpers — WSLg routes through PulseAudio, so `paplay` is
+the one that works:
+
+```bash
+sudo apt install pulseaudio-utils alsa-utils
+```
+
+Verify outside the app before blaming the C#:
+
+```bash
+paplay bin/linux/sound/SND200.wav
+echo $PULSE_SERVER # empty under WSLg → wsl --shutdown and retry
+```
+
+**Format matters on Windows.** `SoundPlayer` handles PCM WAV only:
+
+```bash
+file sound/SND200.wav # want: RIFF ... WAVE audio, Microsoft PCM
+```
+
+**`Stop()` is a no-op on Windows** — `SoundPlayer.Play()` returns no
+handle. If stop-on-back is needed there, use `PlaySync()` on a
+background thread or add NAudio.
+
+---
+
+## 18. Build & Run
+
+Debug build (keeps the console so `Console.WriteLine` diagnostics show):
+
+```bash
+mcs -pkg:gtk-sharp-3.0 *.cs -out:./bin/linux/program.exe \
+ && cp homepageallv3.glade cases.csv ./bin/linux/ \
+ && cp -r map sound ./bin/linux/ \
+ && mono ./bin/linux/program.exe
+```
+
+The `cp` steps are not optional — stale assets in `bin/linux/` are a
+recurring source of phantom bugs (§16).
+
+Re-copying every wav on each build gets slow. Once stable, symlink and
+drop the `cp -r`:
+
+```bash
+ln -s ../../map bin/linux/map
+ln -s ../../sound bin/linux/sound
+```
+
+Release build (`-target:winexe` suppresses the console — don't use it
+while debugging):
+
+```bash
+mcs -target:winexe -pkg:gtk-sharp-3.0 *.cs -out:./bin/windows/program.exe
+```
+
+---
+
+## 19. Debugging Autoconnect Failures
+
+An unbound `[UI]` field is `null` with no warning. Add an explicit guard
+to every controller constructor so the failure names the widget at
+startup:
+
+```csharp
+builder.Autoconnect(this);
+
+if (ConditionNameLabel == null || SoundButtonGrid == null || SoundBackButton == null)
+ throw new InvalidOperationException(
+ "Glade id mismatch — ConditionNameLabel=" + (ConditionNameLabel != null) +
+ " SoundButtonGrid=" + (SoundButtonGrid != null) +
+ " SoundBackButton=" + (SoundBackButton != null));
+```
+
+Output looks like:
+
+```
+System.InvalidOperationException: Glade id mismatch —
+ ConditionNameLabel=False SoundButtonGrid=True SoundBackButton=True
+```
+
+For classes with many fields, loop a dictionary instead:
+
+```csharp
+foreach (var pair in new Dictionary {
+ { "ComPortLabel", ComPortLabel }, { "ConnectButton", ConnectButton },
+ { "SettingBackButton", SettingBackButton }, /* ... */ })
+ if (pair.Value == null)
+ throw new InvalidOperationException("Glade id not bound: " + pair.Key);
+```
+
+### Reading a GTK# stack trace
+
+Exceptions inside signal handlers arrive wrapped:
+
+```
+System.Reflection.TargetInvocationException ---> System.NullReferenceException
+ at SoundWindow.Render () [0x0007b]
+```
+
+Ignore the `GLib.SignalClosure` / `MarshalCallback` frames — they're
+plumbing. The first frame naming **your** class is the real site, and
+the `[0x...]` IL offset distinguishes lines within it.
+
+### Common failures
+
+| Symptom | Cause | Fix |
+|---|---|---|
+| NRE in controller ctor | `[UI]` field id mismatch | §19 guard, then fix the glade id |
+| NRE on first click, no CSV log line | Controller never constructed | Assign it in the **private** `MainWindow(Builder)` ctor |
+| Buttons don't appear, no error | Missing `ShowAll()` | Call it on the container after `Attach` |
+| Every button does the same thing | Closure over loop variable | `string captured = text;` |
+| `Loaded 0 cases` | Wrong path or delimiter | Use `AppFile()`; check tab vs comma |
+| Button sits left despite `halign=end` | Relying on grid placeholders | Add `hexpand=True` |
+| Fix appears to do nothing | Editing a different copy of the glade | Use `AppFile()` + `cp` on build |
+| Japanese renders as garbage | Wrong encoding | `File.ReadAllLines(path, Encoding.UTF8)` |
+| GTK warning about scrolling | Grid directly in ScrolledWindow | Keep the `GtkViewport` |
+
+---
+
+## 20. Windows Deployment Differences
+
+The C#, the glade file, and the CSV are **identical**. Four differences:
+
+1. **Audio** — see §17. PCM-only, and `Stop()` doesn't work.
+2. **Build target** — `-target:winexe` for no console window.
+3. **Runtime** — Mono for Windows must be installed on the target
+ machine; it bundles the GTK# runtime.
+4. **Serial ports** — `ArduinoConnection` enumerates `COM*` rather than
+ `/dev/ttyUSB*` / `/dev/ttyACM*`. Mono's Linux `SerialPort.GetPortNames()`
+ has historically missed devices; verify against `ls /dev/tty*`.
+
+Ship this folder:
+
+```
+program.exe
+homepageallv3.glade
+cases.csv
+map/
+sound/
+```
+
+Not problems, for the record: forward slashes in CSV paths work fine on
+Windows, and Windows' case-insensitivity means anything working on Linux
+also works there — never the reverse. Develop on Linux and Windows
+comes free.
\ No newline at end of file
diff --git a/ReadmeQuick.md b/ReadmeQuick.md
index f227bb0..768dd98 100644
--- a/ReadmeQuick.md
+++ b/ReadmeQuick.md
@@ -14,4 +14,9 @@
# Program referencing that DLL
mcs Consumer.cs -r:MyLibrary.dll -out:Consumer.exe
+
+#run with edit
+GTK_DEBUG=interactive mono ./bin/linux/program.exe
+
```
+
diff --git a/program/ArduinoConnection.cs b/program/ArduinoConnection.cs
new file mode 100644
index 0000000..6edc385
--- /dev/null
+++ b/program/ArduinoConnection.cs
@@ -0,0 +1,373 @@
+using System;
+using System.IO.Ports;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+
+public class ArduinoConnection : IDisposable
+{
+ public const int BaudRate = 115200;
+
+ // Must match what your sketch replies to "REQUEST_ID" (Form2.cs: EXPECTED_DEVICE_ID)
+ // public const string ExpectedDeviceId = "EARS_READER";
+ public const string ExpectedDeviceId = "N_EARS_ESP32C3";
+
+ private const int HeartbeatTimeoutMs = 10000;
+ private const int HeartbeatCheckMs = 2000;
+ private const int MaxReconnectAttempts = 5;
+ private const int ResetSettleMs = 1500; // board reboots when DTR asserts
+
+ private readonly object _gate = new object();
+ private SerialPort _port;
+ private Thread _reader;
+ private volatile bool _reading;
+
+ private DateTime _lastData = DateTime.MinValue;
+ private uint _heartbeatId;
+ private bool _autoReconnect;
+ private int _reconnectAttempts;
+ private DateTime _nextReconnect = DateTime.MinValue;
+
+ /// Raised on the GTK main thread for every complete line from the device.
+ public event Action LineReceived;
+ /// Raised on the GTK main thread with human-readable status text.
+ public event Action Log;
+ /// Raised on the GTK main thread when the link goes up or down.
+ public event Action ConnectionChanged;
+
+ public string PortName { get; private set; }
+
+ public bool IsOpen
+ {
+ get { lock (_gate) return _port != null && _port.IsOpen; }
+ }
+
+ public static string[] ListPorts()
+ {
+ return SerialPort.GetPortNames()
+ .Distinct()
+ .OrderBy(p => p, StringComparer.Ordinal)
+ .ToArray();
+ }
+
+ // ---------- public API ----------
+
+ public bool Connect(string portName)
+ {
+ if (string.IsNullOrEmpty(portName))
+ {
+ Emit(Log, "No port selected");
+ return false;
+ }
+
+ ClosePort(silent: true);
+
+ if (!OpenPort(portName))
+ return false;
+
+ _autoReconnect = false;
+ _reconnectAttempts = 0;
+ StartHeartbeat();
+
+ Emit(Log, $"Connected to {portName} @ {BaudRate}");
+ Emit(ConnectionChanged, true);
+
+ // Give the board time to finish booting before the first command.
+ Task.Delay(ResetSettleMs).ContinueWith(_ => Send("MODE3"));
+ return true;
+ }
+
+ public void Disconnect()
+ {
+ _autoReconnect = false;
+ StopHeartbeat();
+ ClosePort(silent: false);
+ }
+
+ public void Send(string command)
+ {
+ try
+ {
+ lock (_gate)
+ {
+ if (_port == null || !_port.IsOpen)
+ {
+ Emit(Log, $"Cannot send '{command}' — not connected");
+ return;
+ }
+ _port.Write(command + "\n");
+ }
+ Emit(Log, $"Sent: {command}");
+ }
+ catch (Exception ex)
+ {
+ Emit(Log, $"Send failed: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Probes every port for a device answering REQUEST_ID with the expected ID.
+ /// Returns the port name, or null. Runs off the UI thread; the caller
+ /// connects on the main thread so no GLib call happens from a worker.
+ ///
+ public Task DetectPortAsync()
+ {
+ return Task.Run(() =>
+ {
+ foreach (string p in ListPorts())
+ {
+ if (IsOpen && p == PortName) continue;
+
+ Emit(Log, $"Probing {p}...");
+ string id;
+ if (Probe(p, out id))
+ {
+ Emit(Log, $"Found {ExpectedDeviceId} on {p}");
+ return p;
+ }
+ if (id != null)
+ Emit(Log, $" {p}: reported '{id}' (not a match)");
+ }
+ return (string)null;
+ });
+ }
+
+ // ---------- port plumbing ----------
+
+ private bool OpenPort(string portName)
+ {
+ try
+ {
+ lock (_gate)
+ {
+ _port = new SerialPort(portName, BaudRate)
+ {
+ ReadTimeout = 500,
+ WriteTimeout = 1000,
+ NewLine = "\n",
+ DtrEnable = true,
+ RtsEnable = true,
+ };
+ _port.Open();
+ }
+
+ PortName = portName;
+ _lastData = DateTime.Now;
+ StartReader();
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Emit(Log, $"Open failed on {portName}: {ex.Message}");
+ lock (_gate)
+ {
+ _port?.Dispose();
+ _port = null;
+ }
+ return false;
+ }
+ }
+
+ private void ClosePort(bool silent)
+ {
+ _reading = false;
+
+ Thread t = _reader;
+ _reader = null;
+ if (t != null && t.IsAlive && t != Thread.CurrentThread)
+ t.Join(1000);
+
+ lock (_gate)
+ {
+ try { if (_port != null && _port.IsOpen) _port.Close(); }
+ catch (Exception ex) { Emit(Log, $"Close error: {ex.Message}"); }
+ _port?.Dispose();
+ _port = null;
+ }
+
+ if (!silent)
+ {
+ Emit(Log, "Disconnected");
+ Emit(ConnectionChanged, false);
+ }
+ }
+
+ private void StartReader()
+ {
+ _reading = true;
+ _reader = new Thread(ReaderLoop)
+ {
+ IsBackground = true,
+ Name = "arduino-reader"
+ };
+ _reader.Start();
+ }
+
+ private void ReaderLoop()
+ {
+ var pending = new StringBuilder();
+ var chunk = new byte[4096];
+
+ while (_reading)
+ {
+ SerialPort p;
+ lock (_gate) p = _port;
+
+ if (p == null || !p.IsOpen) { Thread.Sleep(100); continue; }
+
+ try
+ {
+ int n = p.Read(chunk, 0, chunk.Length);
+ if (n <= 0) continue;
+
+ _lastData = DateTime.Now;
+ pending.Append(Encoding.ASCII.GetString(chunk, 0, n));
+
+ string buffered = pending.ToString();
+ int nl;
+ while ((nl = buffered.IndexOf('\n')) >= 0)
+ {
+ string line = buffered.Substring(0, nl).Trim();
+ buffered = buffered.Substring(nl + 1);
+ if (line.Length > 0)
+ Emit(LineReceived, line);
+ }
+ pending.Clear();
+ pending.Append(buffered);
+ }
+ catch (TimeoutException) { /* normal when idle */ }
+ catch (Exception ex)
+ {
+ if (_reading) Emit(Log, $"Read error: {ex.Message}");
+ Thread.Sleep(200);
+ }
+ }
+ }
+
+ private bool Probe(string portName, out string deviceId)
+ {
+ deviceId = null;
+ SerialPort test = null;
+ try
+ {
+ test = new SerialPort(portName, BaudRate)
+ {
+ ReadTimeout = 500,
+ WriteTimeout = 1000,
+ NewLine = "\n",
+ DtrEnable = true,
+ RtsEnable = true,
+ };
+ test.Open();
+ Thread.Sleep(ResetSettleMs);
+ test.DiscardInBuffer();
+ test.Write("REQUEST_ID\n");
+
+ DateTime deadline = DateTime.Now.AddSeconds(3);
+ while (DateTime.Now < deadline)
+ {
+ try
+ {
+ string line = test.ReadLine().Trim();
+ if (line.StartsWith("DEVICE_ID:"))
+ {
+ deviceId = line.Substring("DEVICE_ID:".Length).Trim();
+ return deviceId == ExpectedDeviceId;
+ }
+ }
+ catch (TimeoutException) { }
+ }
+ }
+ catch (Exception ex)
+ {
+ Emit(Log, $" {portName}: {ex.Message}");
+ }
+ finally
+ {
+ try { test?.Close(); } catch { }
+ test?.Dispose();
+ }
+ return false;
+ }
+
+ // ---------- heartbeat / auto-reconnect (runs on the GTK main loop) ----------
+
+ private void StartHeartbeat()
+ {
+ StopHeartbeat();
+ _heartbeatId = GLib.Timeout.Add(HeartbeatCheckMs, OnHeartbeatTick);
+ }
+
+ private void StopHeartbeat()
+ {
+ if (_heartbeatId != 0)
+ {
+ GLib.Source.Remove(_heartbeatId);
+ _heartbeatId = 0;
+ }
+ }
+
+ private bool OnHeartbeatTick()
+ {
+ if (_autoReconnect && !IsOpen)
+ {
+ if (_reconnectAttempts >= MaxReconnectAttempts)
+ {
+ _autoReconnect = false;
+ Log?.Invoke($"Reconnection failed after {MaxReconnectAttempts} attempts. Check USB cable, port and power, then reconnect manually.");
+ StopHeartbeat();
+ return false;
+ }
+
+ if (DateTime.Now >= _nextReconnect)
+ {
+ _reconnectAttempts++;
+ Log?.Invoke($"Reconnect attempt {_reconnectAttempts}/{MaxReconnectAttempts} on {PortName}...");
+
+ if (OpenPort(PortName))
+ {
+ _autoReconnect = false;
+ _reconnectAttempts = 0;
+ Log?.Invoke("Reconnected");
+ ConnectionChanged?.Invoke(true);
+ Task.Delay(ResetSettleMs).ContinueWith(_ => Send("MODE3"));
+ }
+ else
+ {
+ _nextReconnect = DateTime.Now.AddSeconds(2);
+ }
+ }
+ return true;
+ }
+
+ if (!IsOpen) return true;
+
+ if ((DateTime.Now - _lastData).TotalMilliseconds >= HeartbeatTimeoutMs)
+ {
+ Log?.Invoke("Heartbeat timeout — no device activity");
+ ClosePort(silent: true);
+ ConnectionChanged?.Invoke(false);
+ _autoReconnect = true;
+ _reconnectAttempts = 0;
+ _nextReconnect = DateTime.Now.AddSeconds(2);
+ }
+ return true;
+ }
+
+ // ---------- helpers ----------
+
+ private static void Emit(Action handler, T arg)
+ {
+ Action h = handler;
+ if (h == null) return;
+ Gtk.Application.Invoke((s, e) => h(arg));
+ }
+
+ public void Dispose()
+ {
+ _autoReconnect = false;
+ StopHeartbeat();
+ ClosePort(silent: true);
+ }
+}
\ No newline at end of file
diff --git a/program/CaseDefinition.cs b/program/CaseDefinition.cs
new file mode 100644
index 0000000..39b9c85
--- /dev/null
+++ b/program/CaseDefinition.cs
@@ -0,0 +1,143 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Text;
+
+/// One row of cases.csv.
+public class CaseDefinition
+{
+ public int Number { get; set; } // Number
+ public string Type { get; set; } // Type 心音 / 呼吸音
+ public string CategoryJp { get; set; } // Category
+ public string SubcategoryJp { get; set; } // Subcategory
+ public string LocationJp { get; set; } // Location
+ public string TreeLevel1 { get; set; } // Tree_Level1
+ public string TreeLevel2 { get; set; } // Tree_Level2
+ public string TreeLevel3 { get; set; } // Tree_Level3
+ public string MapFront { get; set; } // Image_File
+ public string SoundPath { get; set; } // Sound_File
+ public string MapRight { get; set; } // Image_Right
+ public string MapLeft { get; set; } // Image_Left
+ public string MapBack { get; set; } // Image_Back
+
+ public bool IsHeart
+ {
+ get { return !string.IsNullOrEmpty(Type) && Type.Contains("心"); }
+ }
+
+ /// Non-empty levels only, e.g. 呼吸音 → 正常呼吸音 → 気管音.
+ /// Depth varies per row, which is what drives the drill-down.
+ public string[] TreePath
+ {
+ get
+ {
+ var parts = new List();
+ foreach (string s in new[] { Type, TreeLevel1, TreeLevel2, TreeLevel3 })
+ if (!string.IsNullOrWhiteSpace(s)) parts.Add(s.Trim());
+ return parts.ToArray();
+ }
+ }
+
+ public override string ToString()
+ {
+ return Number + ": " + string.Join(" : ", TreePath);
+ }
+
+ // ── loader ─────────────────────────────────────────────────────────
+
+ public static List LoadCasesFromCsv(string path)
+ {
+ var list = new List();
+
+ if (!File.Exists(path))
+ {
+ Console.WriteLine("CSV not found: " + path);
+ return list;
+ }
+
+ // Encoding.UTF8 with BOM detection. Reading Japanese as the default
+ // ANSI codepage gives mojibake that only shows up on the buttons.
+ string[] lines;
+ try
+ {
+ lines = File.ReadAllLines(path, Encoding.UTF8);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine("Failed to read " + path + ": " + ex.Message);
+ return list;
+ }
+
+ if (lines.Length < 2)
+ {
+ Console.WriteLine("CSV has no data rows: " + path);
+ return list;
+ }
+
+ // Your file is TAB separated; fall back to comma if it gets re-exported.
+ char sep = lines[0].Contains("\t") ? '\t' : ',';
+
+ // Header name -> column index, so adding or reordering columns is safe.
+ // string[] header = lines[0].TrimStart('\uFEFF').Split(sep);
+ string[] header = lines[0].TrimStart(new[] { '\uFEFF' }).Split(sep);
+ var idx = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ for (int i = 0; i < header.Length; i++)
+ {
+ string key = header[i].Trim();
+ if (key.Length > 0 && !idx.ContainsKey(key)) idx[key] = i;
+ }
+
+ if (!idx.ContainsKey("Number"))
+ {
+ Console.WriteLine("CSV header has no 'Number' column — wrong file or wrong delimiter?");
+ return list;
+ }
+
+ Func get = (fields, name) =>
+ {
+ int i;
+ if (!idx.TryGetValue(name, out i) || i >= fields.Length) return "";
+ return fields[i].Trim();
+ };
+
+ int skipped = 0;
+
+ for (int r = 1; r < lines.Length; r++)
+ {
+ if (string.IsNullOrWhiteSpace(lines[r])) continue;
+
+ string[] f = lines[r].Split(sep);
+
+ int number;
+ if (!int.TryParse(get(f, "Number"), NumberStyles.Integer,
+ CultureInfo.InvariantCulture, out number))
+ {
+ skipped++;
+ Console.WriteLine("Line " + (r + 1) + ": bad Number, skipped");
+ continue;
+ }
+
+ list.Add(new CaseDefinition
+ {
+ Number = number,
+ Type = get(f, "Type"),
+ CategoryJp = get(f, "Category"),
+ SubcategoryJp = get(f, "Subcategory"),
+ LocationJp = get(f, "Location"),
+ TreeLevel1 = get(f, "Tree_Level1"),
+ TreeLevel2 = get(f, "Tree_Level2"),
+ TreeLevel3 = get(f, "Tree_Level3"),
+ MapFront = get(f, "Image_File"),
+ SoundPath = get(f, "Sound_File"),
+ MapRight = get(f, "Image_Right"),
+ MapLeft = get(f, "Image_Left"),
+ MapBack = get(f, "Image_Back")
+ });
+ }
+
+ Console.WriteLine("Loaded " + list.Count + " cases from " + path +
+ (skipped > 0 ? " (" + skipped + " skipped)" : ""));
+ return list;
+ }
+}
\ No newline at end of file
diff --git a/program/JacketData.cs b/program/JacketData.cs
new file mode 100644
index 0000000..fd9883c
--- /dev/null
+++ b/program/JacketData.cs
@@ -0,0 +1,215 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+
+/// One chip position. Mirrors Form2.Circle.
+public class Circle
+{
+ public float Number { get; set; }
+ public int X { get; set; } // screen-space: OriginalX * 50 + MarginX
+ public int Y { get; set; }
+ public float OriginalX { get; set; }
+ public float OriginalY { get; set; }
+ public float OriginalZ { get; set; }
+ public bool Inner { get; set; } // non-integer Number
+}
+
+/// Loads the 8 CSVs from JacketData/ — front|back|right|left x L|XL.
+public class JacketData
+{
+ public const int CoordScale = 50; // Form2: originalX * 50
+ public const int MarginX = 30;
+ public const int MarginY = 30;
+
+ private const string ConfigFile = "config.txt";
+ private const string DefaultDir = "JacketData";
+
+ private static readonly string[] Views = { "front", "back", "right", "left" };
+ private static readonly string[] Sizes = { "L", "XL" };
+
+ private readonly Dictionary> _l = new Dictionary>();
+ private readonly Dictionary> _xl = new Dictionary>();
+
+ public string Folder { get; private set; }
+ public bool Loaded { get; private set; }
+
+ /// Form2.GetCurrentCSVData()
+ public List Get(string size, string view)
+ {
+ var dict = (size == "XL") ? _xl : _l;
+ List list;
+ return dict.TryGetValue(view, out list) ? list : new List();
+ }
+
+ public bool Has(string size, string view)
+ {
+ var dict = (size == "XL") ? _xl : _l;
+ return dict.ContainsKey(view) && dict[view].Count > 0;
+ }
+
+ // ── folder resolution: config.txt, else JacketData/ next to the exe ──
+
+ public static string ResolveFolder()
+ {
+ string cfg = SoundWindow.AppFile(ConfigFile);
+
+ if (File.Exists(cfg))
+ {
+ try
+ {
+ string saved = File.ReadAllText(cfg).Trim();
+ if (Directory.Exists(saved) && Validate(saved))
+ {
+ Logger.Write("jacket", "folder from config.txt: " + saved);
+ return saved;
+ }
+
+ Logger.Write("jacket", "config.txt path invalid, ignoring: " + saved);
+ }
+ catch (Exception ex)
+ {
+ Logger.Write("jacket", "config.txt unreadable: " + ex.Message);
+ }
+ }
+
+ return SoundWindow.AppFile(DefaultDir);
+ }
+
+ public static void SaveFolder(string folder)
+ {
+ try { File.WriteAllText(SoundWindow.AppFile(ConfigFile), folder); }
+ catch (Exception ex) { Logger.Write("jacket", "could not save config.txt: " + ex.Message); }
+ }
+
+ /// Form1.ValidateCSVFiles — all 8 must be present.
+ public static bool Validate(string folder)
+ {
+ foreach (string v in Views)
+ foreach (string s in Sizes)
+ if (!File.Exists(Path.Combine(folder, v + "_" + s + ".csv")))
+ return false;
+ return true;
+ }
+
+ public static string[] MissingFiles(string folder)
+ {
+ var missing = new List();
+ foreach (string v in Views)
+ foreach (string s in Sizes)
+ {
+ string name = v + "_" + s + ".csv";
+ if (!File.Exists(Path.Combine(folder, name))) missing.Add(name);
+ }
+ return missing.ToArray();
+ }
+
+ // ── loading ─────────────────────────────────────────────────────────
+
+ public static JacketData Load()
+ {
+ return Load(ResolveFolder());
+ }
+
+ public static JacketData Load(string folder)
+ {
+ var data = new JacketData();
+ data.Folder = folder;
+
+ if (!Directory.Exists(folder))
+ {
+ Logger.Write("jacket", "folder not found: " + folder);
+ return data;
+ }
+
+ string[] missing = MissingFiles(folder);
+ if (missing.Length > 0)
+ Logger.Write("jacket", "missing " + missing.Length + " file(s): " + string.Join(", ", missing));
+
+ foreach (string view in Views)
+ {
+ foreach (string size in Sizes)
+ {
+ string path = Path.Combine(folder, view + "_" + size + ".csv");
+ var circles = ReadCsv(path);
+ if (circles.Count == 0) continue;
+
+ if (size == "XL") data._xl[view] = circles;
+ else data._l[view] = circles;
+
+ Logger.Write("jacket", "loaded " + circles.Count + " chips from " + view + "_" + size + ".csv");
+ }
+ }
+
+ data.Loaded = (data._l.Count > 0 || data._xl.Count > 0);
+ if (!data.Loaded) Logger.Write("jacket", "NO chip data loaded from " + folder);
+
+ return data;
+ }
+
+ /// Columns: Number, OriginalX, OriginalY, OriginalZ. Header optional.
+ private static List ReadCsv(string path)
+ {
+ var circles = new List();
+
+ if (!File.Exists(path))
+ {
+ Logger.Write("jacket", "file not found: " + path);
+ return circles;
+ }
+
+ string[] lines;
+ try { lines = File.ReadAllLines(path); }
+ catch (Exception ex)
+ {
+ Logger.Write("jacket", "read failed " + path + ": " + ex.Message);
+ return circles;
+ }
+
+ int start = 0;
+ if (lines.Length > 0)
+ {
+ string h = lines[0].ToLower();
+ if (h.Contains("number") || h.Contains("originalx")) start = 1;
+ }
+
+ for (int i = start; i < lines.Length; i++)
+ {
+ string line = lines[i].Trim();
+ if (line.Length == 0) continue;
+
+ string[] p = line.Split(',');
+ if (p.Length < 4)
+ {
+ Logger.Write("jacket", path + " line " + (i + 1) + ": only " + p.Length + " columns");
+ continue;
+ }
+
+ float number, ox, oy, oz;
+ var inv = CultureInfo.InvariantCulture;
+
+ // InvariantCulture matters — under ja_JP/de_DE, "1.5" can fail to parse.
+ if (!float.TryParse(p[0].Trim(), NumberStyles.Float, inv, out number) ||
+ !float.TryParse(p[1].Trim(), NumberStyles.Float, inv, out ox) ||
+ !float.TryParse(p[2].Trim(), NumberStyles.Float, inv, out oy) ||
+ !float.TryParse(p[3].Trim(), NumberStyles.Float, inv, out oz))
+ {
+ Logger.Write("jacket", path + " line " + (i + 1) + ": bad number format");
+ continue;
+ }
+
+ circles.Add(new Circle
+ {
+ Number = number,
+ X = (int)(ox * CoordScale) + MarginX,
+ Y = (int)(oy * CoordScale) + MarginY,
+ OriginalX = ox,
+ OriginalY = oy,
+ OriginalZ = oz,
+ Inner = (number % 1 != 0)
+ });
+ }
+
+ return circles;
+ }
+}
\ No newline at end of file
diff --git a/program/Language.cs b/program/Language.cs
new file mode 100644
index 0000000..a9afd86
--- /dev/null
+++ b/program/Language.cs
@@ -0,0 +1,146 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using Gtk;
+
+public class Language
+{
+ private const string EngCssPath = "lang_eng.css";
+ private const string JpnCssPath = "lang_jpn.css";
+ private const string EngCsvPath = "info_eng.csv";
+ private const string JpnCsvPath = "info_jpn.csv";
+
+ private readonly Dictionary _engText;
+ private readonly Dictionary _jpnText;
+ private Dictionary _text;
+
+ // widgets whose label is a plain CSV lookup
+ private readonly Dictionary _widgets = new Dictionary();
+
+ public readonly CssProvider _languageCssProvider = new CssProvider();
+
+ public bool IsEnglish { get; private set; }
+
+ /// Raised after every language change, so callers can rebuild state-dependent text.
+ public event EventHandler Changed;
+
+ public Language(bool isEnglish)
+ {
+ StyleContext.AddProviderForScreen(Gdk.Screen.Default, _languageCssProvider, 600);
+
+ _engText = LoadText(EngCsvPath);
+ _jpnText = LoadText(JpnCsvPath);
+
+ // set before any widget is built, so ctor-time lookups are already correct
+ IsEnglish = isEnglish;
+ _text = isEnglish ? _engText : _jpnText;
+ }
+
+ // ---------- registration ----------
+
+ /// Label comes straight from the CSV key. Applied automatically on every change.
+ public void Register(string key, Widget widget)
+ {
+ if (widget == null) return;
+ widget.Name = key; // enables #Key selectors in the lang CSS
+ _widgets[key] = widget;
+ }
+
+ /// Widget whose text the owner sets itself (state-dependent). Name only, no auto-apply.
+ public void RegisterDynamic(string key, Widget widget)
+ {
+ if (widget != null) widget.Name = key;
+ }
+
+ // ---------- lookup ----------
+
+ public string this[string key] { get { return Get(key); } }
+
+ public string Get(string key)
+ {
+ string value;
+ if (_text.TryGetValue(key, out value)) return value;
+
+ Console.WriteLine("Warning: missing text key '" + key + "'");
+ return key;
+ }
+
+ public string Format(string key, params object[] args)
+ {
+ string template = Get(key);
+ try
+ {
+ return string.Format(CultureInfo.InvariantCulture, template, args);
+ }
+ catch (FormatException)
+ {
+ Console.WriteLine("Warning: bad placeholder in text key '" + key + "'");
+ return template;
+ }
+ }
+
+ // ---------- apply ----------
+
+ public void Apply(bool isEnglish)
+ {
+ IsEnglish = isEnglish;
+ _text = isEnglish ? _engText : _jpnText;
+
+ foreach (var kvp in _widgets)
+ {
+ string value;
+ if (_text.TryGetValue(kvp.Key, out value))
+ SetText(kvp.Value, value);
+ else
+ Console.WriteLine("Warning: missing text key '" + kvp.Key + "'");
+ }
+
+ string cssPath = isEnglish ? EngCssPath : JpnCssPath;
+ if (File.Exists(cssPath))
+ _languageCssProvider.LoadFromPath(cssPath);
+ else
+ Console.WriteLine("Warning: CSS file not found: " + cssPath);
+
+ EventHandler handler = Changed;
+ if (handler != null) handler(this, EventArgs.Empty);
+ }
+
+ private static void SetText(Widget widget, string value)
+ {
+ Button button = widget as Button; // also covers ToggleButton
+ if (button != null) { button.Label = value; return; }
+
+ Label label = widget as Label;
+ if (label != null) { label.Text = value; return; }
+
+ Console.WriteLine("Warning: don't know how to set text on " + widget.GetType().Name);
+ }
+
+ // ---------- csv ----------
+
+ private Dictionary LoadText(string path)
+ {
+ var result = new Dictionary();
+
+ if (!File.Exists(path))
+ {
+ Console.WriteLine("Warning: text file not found: " + path);
+ return result;
+ }
+
+ string[] lines = File.ReadAllLines(path);
+ for (int i = 1; i < lines.Length; i++) // row 0 is the header
+ {
+ string line = lines[i].Trim();
+ if (string.IsNullOrEmpty(line) || line.StartsWith("#")) continue;
+
+ string[] parts = line.Split(new[] { ',' }, 2);
+ if (parts.Length < 2) continue;
+
+ result[parts[0].Trim()] = parts[1]; // value keeps any commas
+ }
+
+ return result;
+ }
+}
\ No newline at end of file
diff --git a/program/Logger.cs b/program/Logger.cs
new file mode 100644
index 0000000..85b0efe
--- /dev/null
+++ b/program/Logger.cs
@@ -0,0 +1,69 @@
+using System;
+using System.IO;
+using System.Text;
+
+/// Append-only text log next to the .exe: logs/ears-YYYYMMDD.log
+public static class Logger
+{
+ private static readonly object _gate = new object();
+ private static string _path;
+ private static bool _failed;
+
+ public static void Write(string message)
+ {
+ Write(null, message);
+ }
+
+ public static void Write(string tag, string message)
+ {
+ if (_failed) return;
+
+ string line = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff") +
+ (string.IsNullOrEmpty(tag) ? " " : " [" + tag + "] ") +
+ message;
+
+ lock (_gate)
+ {
+ try
+ {
+ if (_path == null) _path = Init();
+ File.AppendAllText(_path, line + Environment.NewLine, Encoding.UTF8);
+ }
+ catch (Exception ex)
+ {
+ _failed = true; // never let logging crash the app
+ Console.WriteLine("[Logger] disabled: " + ex.Message);
+ }
+ }
+ }
+
+ public static void Write(string tag, string format, params object[] args)
+ {
+ Write(tag, string.Format(format, args));
+ }
+
+ public static void Exception(string tag, Exception ex)
+ {
+ Write(tag, ex.GetType().Name + ": " + ex.Message);
+ Write(tag, ex.StackTrace ?? "(no stack)");
+ }
+
+ private static string Init()
+ {
+ string dir = Path.Combine(
+ Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location),
+ "logs");
+
+ Directory.CreateDirectory(dir);
+
+ string path = Path.Combine(dir, "ears-" + DateTime.Now.ToString("yyyyMMdd") + ".log");
+
+ File.AppendAllText(path,
+ Environment.NewLine +
+ "=== session start " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") +
+ " (" + Environment.OSVersion.Platform + ") ===" + Environment.NewLine,
+ Encoding.UTF8);
+
+ return path;
+ }
+}
\ No newline at end of file
diff --git a/program/MainWindow.cs b/program/MainWindow.cs
new file mode 100644
index 0000000..9a2b13a
--- /dev/null
+++ b/program/MainWindow.cs
@@ -0,0 +1,349 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using Gtk;
+using UI = Gtk.Builder.ObjectAttribute;
+using System.Runtime.InteropServices;
+
+public class MainWindow : Window
+{
+
+ // ---- stack + pages (new all-in-one glade) ----
+ [UI] private Notebook RootNoteBook = null;
+
+ private const int PageSplash = 0;
+ private const int PageSettings = 1;
+ private const int PageSound = 2;
+ private const int PageMap = 3;
+
+ // ---- splash page widgets ----
+ [UI] private Button Option1Button = null;
+ [UI] private Button Option2Button = null;
+ [UI] private Button Option3Button = null;
+ // [UI] private Switch LanguageSwitch = null;
+ [UI] private ToggleButton LanguageToggle = null;
+ [UI] private Label LanguageLabel = null;
+ [UI] private Button CloseButton = null;
+ [UI] private Button SettingButton = null;
+ [UI] private Image ConnectionIcon = null; // was GtkIconView in the old glade
+
+ private CssProvider _scaleCssProvider;
+ // private CssProvider _languageCssProvider;
+ private int _lastAppliedWidth = -1;
+
+ private const int BaseWidth = 1920;
+ private const int BaseHeight = 1080;
+
+ // private const string EngCssPath = "lang_eng.css";
+ // private const string JpnCssPath = "lang_jpn.css";
+
+ // private Dictionary _engText;
+ // private Dictionary _jpnText;
+ // private Dictionary _buttonsById;
+ private bool _connected;
+
+ private readonly ArduinoConnection _arduino = new ArduinoConnection();
+ internal SettingsWindow _settingsWindow;
+ internal Language _Language;
+
+ internal SoundWindow _soundWindow;
+ internal MapWindow _mapWindow;
+
+ public MainWindow() : this(CreateBuilder()) { }
+
+ private static Builder CreateBuilder()
+ {
+ var builder = new Builder();
+ builder.AddFromFile("homepageallv6.glade");
+ return builder;
+ }
+
+ private MainWindow(Builder builder) : base(builder.GetObject("Root").Handle)
+ {
+ builder.Autoconnect(this);
+
+ _scaleCssProvider = new CssProvider();
+ // _languageCssProvider = new CssProvider();
+
+ StyleContext.AddProviderForScreen(Gdk.Screen.Default, _scaleCssProvider, 600);
+ // StyleContext.AddProviderForScreen(Gdk.Screen.Default, _Language._languageCssProvider, 600);
+
+ // make it full screen
+ this.Fullscreen();
+ // //use the linux computer resolution
+ // this.SetDefaultSize(1280, 800);
+ // this.SetPosition(WindowPosition.Center);
+
+ // RootStack.TransitionType = StackTransitionType.None;
+ // RootStack.TransitionType = StackTransitionType.SlideLeftRight;
+ // RootStack.TransitionDuration = 150;
+
+ Option1Button.Clicked += OnNormalSessionClicked;
+ Option2Button.Clicked += OnPositionRecordingClicked;
+ Option3Button.Clicked += OnExamModeClicked;
+ CloseButton.Clicked += OnCloseClicked;
+ SettingButton.Clicked += OnSettingClicked;
+ // LanguageSwitch.AddNotification("active", OnLanguageSwitchNotify);
+ LanguageToggle.Toggled += OnLanguageToggled;
+
+ DeleteEvent += OnDeleteEvent;
+ SizeAllocated += OnWindowSizeAllocated;
+
+ _arduino.ConnectionChanged += OnArduinoConnectionChanged;
+
+
+ // // _buttonsById = new Dictionary
+ // // {
+ // // { "Option1Button", Option1Button },
+ // // { "Option2Button", Option2Button },
+ // // { "Option3Button", Option3Button },
+ // // { "CloseButton", CloseButton },
+ // // { "SettingButton", SettingButton },
+ // // };
+
+ // // CSS ID selectors (#Option1Button etc.) need the widget Name set explicitly
+ // foreach (var kvp in _buttonsById)
+ // kvp.Value.Name = kvp.Key;
+
+ // // _engText = LoadButtonText("info_eng.csv");
+ // // _jpnText = LoadButtonText("info_jpn.csv");
+
+ // _Language = new Language(_buttonsById,LanguageLabel);
+
+ // // _Language.ApplyLanguage(LanguageSwitch.Active);
+
+ _Language = new Language(LanguageToggle.Active);
+ _Language.Register("Option1Button", Option1Button);
+ _Language.Register("Option2Button", Option2Button);
+ _Language.Register("Option3Button", Option3Button);
+ _Language.Register("CloseButton", CloseButton);
+ _Language.Register("SettingButton", SettingButton);
+ _Language.Register("LanguageLabel", LanguageLabel);
+ _Language.Register("LanguageToggle", LanguageToggle);
+ _Language.RegisterDynamic("ConnectionIcon", ConnectionIcon);
+
+ _settingsWindow = new SettingsWindow(builder, _arduino, _Language);
+ _settingsWindow.BackRequested += OnSettingsClosed;
+
+ _mapWindow = new MapWindow(builder, _Language);
+ _mapWindow.BackRequested += OnMapClosed;
+
+ _Language.Changed += (s, e) => OnArduinoConnectionChanged(_connected);
+ _Language.Apply(LanguageToggle.Active);
+
+ // // single source of truth: the toggle drives both pages
+ // ApplyLanguageEverywhere(LanguageToggle.Active);
+
+ RootNoteBook.ShowTabs = false;
+ RootNoteBook.ShowBorder = false;
+ RootNoteBook.CurrentPage = PageSplash;
+ _soundWindow = new SoundWindow(builder);
+ _soundWindow.BackRequested += (s, e) => RootNoteBook.CurrentPage = PageSplash;
+ _soundWindow.CaseSelected += OnCaseSelected;
+
+
+ OnArduinoConnectionChanged(false);
+ }
+
+ // ---------- settings panel ----------
+
+ private void OnSettingClicked(object sender, EventArgs e)
+ {
+ // if (_settingsWindow == null)
+ // {
+ // // _settingsWindow = new SettingsWindow(_arduino);
+ // // _settingsWindow.BackRequested += OnSettingsClosed;
+ // // _settingsWindow.ShowAll(); // build it fullscreen once, up front
+ // // _settingsWindow.Fullscreen();
+ // RootNoteBook.CurrentPage = PageSettings;
+ // }
+
+
+ // _settingsWindow.ShowAll();
+ // _settingsWindow.Present();
+ // this.Hide();
+ RootNoteBook.CurrentPage = PageSettings;
+ }
+
+ private void OnSettingsClosed(object sender, EventArgs e)
+ {
+ // this.ShowAll();
+ // this.Present();
+ // _settingsWindow.Hide();
+ RootNoteBook.CurrentPage = PageSplash;
+ }
+
+ private void OnArduinoConnectionChanged(bool connected)
+ {
+ _connected = connected;
+ if (ConnectionIcon == null) return;
+
+ ConnectionIcon.SetFromIconName(
+ connected ? "network-transmit-receive" : "network-offline",
+ IconSize.Dnd);
+
+ ConnectionIcon.TooltipText = connected
+ ? _Language.Format("ConnectionIcon.Connected", _arduino.PortName)
+ : _Language["ConnectionIcon.Disconnected"];
+ }
+
+ // ---------- localisation ----------
+
+ // private Dictionary LoadButtonText(string path)
+ // {
+ // var result = new Dictionary();
+
+ // if (!File.Exists(path))
+ // {
+ // Console.WriteLine($"Warning: text file not found: {path}");
+ // return result;
+ // }
+
+ // var lines = File.ReadAllLines(path);
+ // for (int i = 1; i < lines.Length; i++) // skip header row
+ // {
+ // var line = lines[i].Trim();
+ // if (string.IsNullOrEmpty(line)) continue;
+
+ // var parts = line.Split(new[] { ',' }, 2);
+ // if (parts.Length < 2) continue;
+
+ // result[parts[0]] = parts[1];
+ // }
+
+ // return result;
+ // }
+
+ // private void ApplyLanguage(bool isEnglish)
+ // {
+ // var text = isEnglish ? _engText : _jpnText;
+ // string cssPath = isEnglish ? EngCssPath : JpnCssPath;
+
+ // foreach (var kvp in _buttonsById)
+ // {
+ // if (text.TryGetValue(kvp.Key, out var buttonText))
+ // kvp.Value.Label = buttonText;
+ // }
+
+ // if (File.Exists(cssPath))
+ // _languageCssProvider.LoadFromPath(cssPath);
+ // else
+ // Console.WriteLine($"Warning: CSS file not found: {cssPath}");
+
+ // LanguageLabel.Text = isEnglish ? "あ" : "A";
+ // }
+
+ // ---------- scaling ----------
+
+ private void OnWindowSizeAllocated(object o, SizeAllocatedArgs args)
+ {
+ int width = args.Allocation.Width;
+ int height = args.Allocation.Height;
+
+ if (width == _lastAppliedWidth) return;
+ _lastAppliedWidth = width;
+
+ double scaleX = (double)width / BaseWidth;
+ double scaleY = (double)height / BaseHeight;
+
+ ApplyScale(Math.Min(scaleX, scaleY));
+ }
+
+ private void ApplyScale(double scale)
+ {
+ int fontSize = Math.Max(10, (int)(20 * scale));
+ int buttonPadV = Math.Max(4, (int)(12 * scale));
+ int buttonPadH = Math.Max(8, (int)(24 * scale));
+ int switchWidth = Math.Max(30, (int)(40 * scale));
+ int switchHeight= Math.Max(18, (int)(24 * scale));
+
+ // NOTE: integers + InvariantCulture. Interpolating a double here emitted
+ // "40,5px" under ja_JP / de_DE locales and GTK silently dropped the rule.
+ string css = string.Format(CultureInfo.InvariantCulture, @"
+ grid, label, button, switch {{
+ font-size: {0}px;
+ }}
+ button {{
+ padding: {1}px {2}px;
+ }}
+ switch {{
+ min-width: {3}px;
+ min-height: {4}px;
+ }}
+ ", fontSize, buttonPadV, buttonPadH, switchWidth, switchHeight);
+
+ _scaleCssProvider.LoadFromData(css);
+ }
+
+ // ---------- lifecycle ----------
+
+ private void OnDeleteEvent(object sender, DeleteEventArgs a)
+ {
+ if (_mapWindow != null) _mapWindow.StopSession();
+ Application.Quit();
+ a.RetVal = true;
+ // Shutdown();
+ // a.RetVal = true;
+ }
+
+ private void OnCloseClicked(object sender, EventArgs e)
+ {
+ Shutdown();
+ }
+
+ private void Shutdown()
+ {
+ _arduino.Disconnect();
+ _arduino.Dispose();
+ // _settingsWindow?.Destroy();
+ Application.Quit();
+ }
+
+ private void OnNormalSessionClicked(object sender, EventArgs e)
+ {
+ Console.WriteLine("Normal Session selected");
+ RootNoteBook.CurrentPage = PageSound;
+ _soundWindow.Reset();
+ }
+
+ private void OnPositionRecordingClicked(object sender, EventArgs e)
+ {
+ Console.WriteLine("Position Recording Mode selected");
+ }
+
+ private void OnExamModeClicked(object sender, EventArgs e)
+ {
+ Console.WriteLine("Exam Mode selected");
+ }
+
+ private void OnLanguageToggled(object sender, EventArgs e)
+ {
+ _Language.Apply(LanguageToggle.Active);
+ }
+
+ // private void ApplyLanguageEverywhere(bool isEnglish)
+ // {
+ // Language.LanguageSwitchValue = isEnglish;
+ // _Language.ApplyLanguage(isEnglish);
+ // _settingsWindow?._Language?.ApplyLanguages(isEnglish);
+ // }
+
+ private void OnCaseSelected(object sender, CaseDefinition def)
+ {
+ // _mapWindow.ShowCase(
+ // string.Join(" : ", def.TreePath), // no DisplayName on CaseDefinition
+ // SoundWindow.AppFile("body/front.png"),
+ // SoundWindow.AppFile(def.MapFront),
+ // SoundWindow.AppFile(def.SoundPath));
+
+ // RootNoteBook.CurrentPage = PageMap;
+ _mapWindow.ShowCase(def, string.Join(" : ", def.TreePath));
+ RootNoteBook.CurrentPage = PageMap;
+ }
+
+ private void OnMapClosed(object sender, EventArgs e)
+ {
+ RootNoteBook.CurrentPage = PageSound; // back to disease list
+ }
+}
\ No newline at end of file
diff --git a/program/MapWindow.cs b/program/MapWindow.cs
new file mode 100644
index 0000000..b3915ed
--- /dev/null
+++ b/program/MapWindow.cs
@@ -0,0 +1,454 @@
+using System;
+using System.IO;
+using Gtk;
+using UI = Gtk.Builder.ObjectAttribute;
+
+public class MapWindow : IDisposable
+{
+ // Form2 view offsets — L size
+ private const float FRONT_OFFSET_X = 25.0f, FRONT_OFFSET_Y = 50.0f;
+ private const float BACK_OFFSET_X = 20.0f, BACK_OFFSET_Y = 20.0f;
+ private const float RIGHT_OFFSET_X = 10.0f, RIGHT_OFFSET_Y = -150.0f;
+ private const float LEFT_OFFSET_X = -50.0f, LEFT_OFFSET_Y = -150.0f;
+
+ private const float FRONT_SIZE = 1.0f, BACK_SIZE = 0.5f,
+ RIGHT_SIZE = 1.0f, LEFT_SIZE = 1.0f;
+
+ // XL deltas
+ private const float XL_FRONT_OFFSET_X = -28.0f, XL_FRONT_OFFSET_Y = 24.0f;
+ private const float XL_BACK_OFFSET_X = 8.0f, XL_BACK_OFFSET_Y = -15.0f;
+ private const float XL_RIGHT_OFFSET_X = -41.0f, XL_RIGHT_OFFSET_Y = 8.0f;
+ private const float XL_LEFT_OFFSET_X = 54.0f, XL_LEFT_OFFSET_Y = 0.0f;
+
+ private const int CircleDiameter = 50;
+ // ── glade widgets ──
+ [UI] private DrawingArea MapArea = null;
+ [UI] private Label MapTitleLabel = null;
+ [UI] private Label MapCaseLabel = null;
+ [UI] private Button MapBackButton = null;
+ [UI] private RadioButton BodySkeletonRadio = null;
+ [UI] private RadioButton BodyJacketRadio = null;
+ [UI] private ComboBoxText JacketSizeCombo = null;
+ [UI] private RadioButton ViewFrontRadio = null;
+ [UI] private RadioButton ViewBackRadio = null;
+ [UI] private RadioButton ViewRightRadio = null;
+ [UI] private RadioButton ViewLeftRadio = null;
+ [UI] private CheckButton HideCircleCheck = null;
+
+ private readonly Language _lang;
+ private readonly SoundLooper _sound = new SoundLooper();
+
+ // ── state ──
+ private Gdk.Pixbuf _body; // background, native size
+ private Gdk.Pixbuf _map; // 546x546, black keyed out — also the audio reference
+ private CaseDefinition _case;
+ private string _view = "front";
+
+ private const int MapSize = 546; // Form2: new Bitmap(mapImage, 546, 546)
+ private const double MapAlpha = 0.7; // Form2: CreateOverlayImage(..., 0.7f, ...)
+
+ public event EventHandler BackRequested;
+
+ private JacketData _jacket;
+ private System.Collections.Generic.List _highlighted = new System.Collections.Generic.List();
+ private static bool _listed;
+
+ public MapWindow(Builder builder, Language language)
+ {
+ builder.Autoconnect(this);
+
+ _jacket = JacketData.Load();
+ Logger.Write("jacket", "folder: " + _jacket.Folder + " loaded=" + _jacket.Loaded);
+
+ if (MapArea == null || MapTitleLabel == null || MapBackButton == null)
+ throw new InvalidOperationException(
+ "Glade id mismatch — MapArea=" + (MapArea != null) +
+ " MapTitleLabel=" + (MapTitleLabel != null) +
+ " MapBackButton=" + (MapBackButton != null));
+
+ _lang = language;
+
+ MapArea.Drawn += OnMapDrawn;
+ MapBackButton.Clicked += OnBackClicked;
+
+ BodySkeletonRadio.Toggled += (s, e) => { if (BodySkeletonRadio.Active) ReloadBody(); };
+ BodyJacketRadio.Toggled += (s, e) => { if (BodyJacketRadio.Active) ReloadBody(); };
+ JacketSizeCombo.Changed += (s, e) => { ReloadBody(); MapArea.QueueDraw(); };
+
+ HookView(ViewFrontRadio, "front");
+ HookView(ViewBackRadio, "back");
+ HookView(ViewRightRadio, "right");
+ HookView(ViewLeftRadio, "left");
+
+ HideCircleCheck.Toggled += (s, e) => MapArea.QueueDraw();
+
+ _sound.Log += msg => Logger.Write("sound", msg);
+ }
+
+ private void HookView(RadioButton rb, string view)
+ {
+ if (rb == null) return;
+ rb.Toggled += (s, e) => { if (rb.Active) SetView(view); };
+ }
+
+ // ── public API ─────────────────────────────────────────────────────
+
+ public void ShowCase(CaseDefinition def, string title)
+ {
+ _case = def;
+ _view = "front";
+ if (ViewFrontRadio != null) ViewFrontRadio.Active = true;
+
+ MapTitleLabel.Text = title ?? "";
+ MapCaseLabel.Text = title ?? "-";
+
+ DisposePixbufs();
+ _body = LoadBody();
+ _map = LoadMap(MapPathForView());
+
+ MapArea.QueueDraw();
+ _sound.Start(SoundWindow.AppFile(def.SoundPath));
+
+ Logger.Write("case", "open: " + title + " | map=" + MapPathForView());
+ }
+
+ public void StopSession()
+ {
+ _sound.Stop();
+ Logger.Write("case", "closed");
+ }
+
+ /// Raw MAP pixbuf — Form2's originalMapForAudio. Never composited.
+ public Gdk.Pixbuf AudioReference { get { return _map; } }
+
+ // ── the paint ──────────────────────────────────────────────────────
+
+ private void OnMapDrawn(object o, DrawnArgs args)
+ {
+ var cr = args.Cr;
+ int w = MapArea.AllocatedWidth;
+ int h = MapArea.AllocatedHeight;
+
+ if (_body == null) { args.RetVal = true; return; }
+
+ // ONE scale factor, derived from the body — this is what keeps the
+ // overlay registered. Zoom-to-fit, same as PictureBoxSizeMode.Zoom.
+ double s = Math.Min((double)w / _body.Width, (double)h / _body.Height);
+ double ox = (w - _body.Width * s) / 2.0;
+ double oy = (h - _body.Height * s) / 2.0;
+
+ cr.Save();
+ cr.Translate(ox, oy);
+ cr.Scale(s, s); // everything below is in BODY PIXELS
+
+ Gdk.CairoHelper.SetSourcePixbuf(cr, _body, 0, 0);
+ cr.Paint();
+
+ if (_map != null)
+ {
+ // CreateOverlayImage(position: null) == centre the MAP on the body
+ double mx = (_body.Width - _map.Width) / 2.0;
+ double my = (_body.Height - _map.Height) / 2.0;
+
+ Gdk.CairoHelper.SetSourcePixbuf(cr, _map, mx, my);
+ cr.PaintWithAlpha(MapAlpha);
+ }
+
+ if (!HideCircleCheck.Active)
+ DrawCircles(cr); // body-pixel coords; transform does the rest
+
+ cr.Restore();
+ args.RetVal = true;
+ }
+
+ private void DrawCircles(Cairo.Context cr)
+ {
+ if (_jacket == null || _body == null) return;
+
+ var circles = _jacket.Get(JacketSize, _view);
+ if (circles.Count == 0) return;
+
+ // "panel" is the body pixbuf — Form2's gridPictureBox was 546x546
+ int panelW = _body.Width;
+ int panelH = _body.Height;
+
+ float scale = ScalingFactor(circles, panelW, panelH);
+
+ float minX = float.MaxValue, maxX = float.MinValue;
+ float minY = float.MaxValue, maxY = float.MinValue;
+ foreach (var c in circles)
+ {
+ if (c.X < minX) minX = c.X;
+ if (c.X > maxX) maxX = c.X;
+ if (c.Y < minY) minY = c.Y;
+ if (c.Y > maxY) maxY = c.Y;
+ }
+
+ float offsetX = (panelW - (maxX - minX) * scale) / 2 - minX * scale;
+ float offsetY = (panelH - (maxY - minY) * scale) / 2 - minY * scale;
+
+ cr.SelectFontFace("Sans", Cairo.FontSlant.Normal, Cairo.FontWeight.Bold);
+
+ foreach (var c in circles)
+ DrawOneCircle(cr, c, scale, offsetX, offsetY);
+ }
+
+ private float ScalingFactor(System.Collections.Generic.List circles, int panelW, int panelH)
+ {
+ if (circles.Count == 0) return 1.0f;
+
+ int availW = panelW - 40, availH = panelH - 40;
+ if (_view == "front") { availW = panelW - 100; availH = panelH - 100; }
+
+ float minX = float.MaxValue, maxX = float.MinValue;
+ float minY = float.MaxValue, maxY = float.MinValue;
+ foreach (var c in circles)
+ {
+ if (c.X < minX) minX = c.X;
+ if (c.X > maxX) maxX = c.X;
+ if (c.Y < minY) minY = c.Y;
+ if (c.Y > maxY) maxY = c.Y;
+ }
+
+ const float circleRadius = 2.1f / 2;
+ float dataW = (maxX - minX) + circleRadius * 2;
+ float dataH = (maxY - minY) + circleRadius * 2;
+
+ if (dataW <= 0 || dataH <= 0) return 1.0f;
+
+ return Math.Min(availW / dataW, availH / dataH) * 0.9f;
+ }
+
+ private void DrawOneCircle(Cairo.Context cr, Circle c, float scale, float ox, float oy)
+ {
+ float x, y, sizeScale;
+
+ switch (_view)
+ {
+ case "back":
+ x = c.X * scale + ox - BACK_OFFSET_X;
+ y = c.Y * scale + oy - BACK_OFFSET_Y;
+ sizeScale = BACK_SIZE;
+ break;
+ case "right":
+ x = c.X * scale + ox - RIGHT_OFFSET_X;
+ y = c.Y * scale + oy - RIGHT_OFFSET_Y;
+ sizeScale = RIGHT_SIZE;
+ break;
+ case "left":
+ x = c.X * scale + ox - LEFT_OFFSET_X;
+ y = c.Y * scale + oy - LEFT_OFFSET_Y;
+ sizeScale = LEFT_SIZE;
+ break;
+ default:
+ x = c.X * scale + ox - FRONT_OFFSET_X;
+ y = c.Y * scale + oy - FRONT_OFFSET_Y;
+ sizeScale = FRONT_SIZE;
+ break;
+ }
+
+ if (JacketSize == "XL")
+ {
+ switch (_view)
+ {
+ case "back": x += XL_BACK_OFFSET_X; y += XL_BACK_OFFSET_Y; break;
+ case "right": x += XL_RIGHT_OFFSET_X; y += XL_RIGHT_OFFSET_Y; break;
+ case "left": x += XL_LEFT_OFFSET_X; y += XL_LEFT_OFFSET_Y; break;
+ default: x += XL_FRONT_OFFSET_X; y += XL_FRONT_OFFSET_Y; break;
+ }
+ }
+
+ double d = CircleDiameter * scale * sizeScale;
+ double r = d / 2;
+
+ bool hot = false;
+ foreach (float n in _highlighted)
+ if (Math.Abs(c.Number - n) < 0.001f) { hot = true; break; }
+
+ cr.NewPath();
+ cr.Arc(x, y, r, 0, 2 * Math.PI);
+
+ if (hot)
+ {
+ // cr.SetSourceRGBA(1.0, 0.922, 0.231, 1.0); // Color.FromArgb(255,235,59)
+ // cr.Arc(x, y, r, 0, 2 * Math.PI);
+ // cr.FillPreserve();
+ cr.SetSourceRGBA(1.0, 0.922, 0.231, 1.0);
+ cr.FillPreserve();
+ }
+ // else
+ // {
+ // cr.Arc(x, y, r, 0, 2 * Math.PI);
+ // }
+
+ cr.SetSourceRGBA(0.5, 0.5, 0.5, 1.0); // BorderColor = Gray
+ cr.LineWidth = Math.Max(1.0, scale);
+ cr.Stroke();
+
+ string text = (c.Number % 1 == 0)
+ ? ((int)c.Number).ToString()
+ : c.Number.ToString("F1");
+
+ cr.SetFontSize(Math.Max(6.0, d / (c.Inner ? 3 : 4)));
+ var ext = cr.TextExtents(text);
+ cr.MoveTo(x - ext.Width / 2 - ext.XBearing, y + ext.Height / 2);
+ cr.SetSourceRGBA(0.2, 0.2, 0.2, 1.0); // TextColor
+ cr.ShowText(text);
+ cr.NewPath();
+ }
+
+ // ── loading ────────────────────────────────────────────────────────
+
+ private void SetView(string view)
+ {
+ if (_view == view) return;
+ _view = view;
+
+ ReloadBody();
+
+ if (_map != null) { _map.Dispose(); _map = null; }
+ _map = LoadMap(MapPathForView());
+
+ MapArea.QueueDraw();
+ Logger.Write("view", "-> " + view);
+ }
+
+ private void ReloadBody()
+ {
+ if (_body != null) { _body.Dispose(); _body = null; }
+ _body = LoadBody();
+ MapArea.QueueDraw();
+ }
+
+ private Gdk.Pixbuf LoadBody()
+ {
+ string stem = BodyStem();
+ string full = SoundWindow.AppFile(Path.Combine("map", stem + ".png"));
+
+ if (!File.Exists(full))
+ {
+ Logger.Write("body", "not found: map/" + stem + ".png");
+ ListFolderOnce("map");
+ return null;
+ }
+
+ return Load(full);
+ // string type = (BodyJacketRadio != null && BodyJacketRadio.Active) ? "jacket" : "skel";
+ // string size = (JacketSizeCombo != null ? JacketSizeCombo.ActiveText : null) ?? "L";
+
+ // // Adjust to your actual body/ filenames.
+ // string rel = Path.Combine("body", type + "_" + _view + "_" + size + ".png");
+ // return Load(SoundWindow.AppFile(rel));
+ }
+ private static void ListFolderOnce(string rel)
+ {
+ if (_listed) return;
+ _listed = true;
+
+ string dir = SoundWindow.AppFile(rel);
+ if (!Directory.Exists(dir)) { Logger.Write("body", "folder missing: " + dir); return; }
+
+ foreach (string f in Directory.GetFiles(dir))
+ Logger.Write("body", "found: " + Path.GetFileName(f));
+ }
+
+ private string BodyStem()
+ {
+ bool skeleton = (BodySkeletonRadio != null && BodySkeletonRadio.Active);
+
+ if (skeleton)
+ {
+ switch (_view)
+ {
+ case "back": return "jacketBackBody";
+ case "right": return "jacketRightBody";
+ case "left": return "jacketLeftBody";
+ default: return "jacketFrontBody";
+ }
+ }
+
+ switch (_view)
+ {
+ case "back": return "jacketBack";
+ case "right": return "jacketRight";
+ case "left": return "jacketLeft";
+ default: return "jacketFront";
+ }
+ }
+
+ private string MapPathForView()
+ {
+ if (_case == null) return null;
+
+ string rel;
+ switch (_view)
+ {
+ case "back": rel = _case.MapBack; break;
+ case "right": rel = _case.MapRight; break;
+ case "left": rel = _case.MapLeft; break;
+ default: rel = _case.MapFront; break;
+ }
+
+ // Form2 falls back to a mirrored front map when there's no dedicated back.
+ if (string.IsNullOrEmpty(rel)) rel = _case.MapFront;
+
+ return string.IsNullOrEmpty(rel) ? null : SoundWindow.AppFile(rel);
+ }
+
+ private static Gdk.Pixbuf Load(string path)
+ {
+ if (string.IsNullOrEmpty(path) || !File.Exists(path))
+ {
+ Logger.Write("map", "image not found: " + path);
+ return null;
+ }
+
+ try { return new Gdk.Pixbuf(path); }
+ catch (Exception ex)
+ {
+ Logger.Write("map", "load failed " + path + ": " + ex.Message);
+ return null;
+ }
+ }
+
+ private static Gdk.Pixbuf LoadMap(string path)
+ {
+ var raw = Load(path);
+ if (raw == null) return null;
+
+ // Key out black FIRST, then scale. Scaling first leaves near-black
+ // edge pixels (1,0,2) that AddAlpha won't match — that's the dark halo.
+ var keyed = raw.AddAlpha(true, 0, 0, 0);
+ raw.Dispose();
+
+ var sized = keyed.ScaleSimple(MapSize, MapSize, Gdk.InterpType.Bilinear);
+ keyed.Dispose();
+ return sized;
+ }
+
+ // ── plumbing ───────────────────────────────────────────────────────
+
+ private void OnBackClicked(object sender, EventArgs e)
+ {
+ StopSession();
+ var h = BackRequested;
+ if (h != null) h(this, EventArgs.Empty);
+ }
+
+ private void DisposePixbufs()
+ {
+ if (_body != null) { _body.Dispose(); _body = null; }
+ if (_map != null) { _map.Dispose(); _map = null; }
+ }
+
+ public void Dispose()
+ {
+ _sound.Dispose();
+ DisposePixbufs();
+ }
+
+ private string JacketSize
+ {
+ get { return (JacketSizeCombo != null ? JacketSizeCombo.ActiveText : null) ?? "L"; }
+ }
+}
\ No newline at end of file
diff --git a/program/SettingWindow.cs b/program/SettingWindow.cs
new file mode 100644
index 0000000..f091c74
--- /dev/null
+++ b/program/SettingWindow.cs
@@ -0,0 +1,217 @@
+using System;
+using Gtk;
+using System.Collections.Generic;
+using UI = Gtk.Builder.ObjectAttribute;
+
+public class SettingsWindow
+{
+ [UI] private Label ComPortLabel = null;
+ [UI] private ComboBoxText ComPortComboBox = null;
+ [UI] private Button ConnectButton = null;
+ [UI] private Button AutoConnectButton = null;
+ [UI] private Button RefreshButton = null;
+ [UI] private Button SettingBackButton = null;
+ [UI] private Label StatusLabel = null;
+ [UI] private Label LogLabel = null;
+ [UI] private TextView LogtextBox = null;
+
+ // private readonly Dictionary _buttonsById;
+ // private readonly Dictionary _labelsById;
+ // internal Language _Language;
+ private readonly Language _lang;
+ private bool _connected;
+
+ private const int MaxLogLines = 500;
+
+ private readonly ArduinoConnection _arduino;
+ private string[] _ports = new string[0];
+
+ /// Raised after the window hides itself, so the caller can re-show the main window.
+ public event EventHandler BackRequested;
+
+
+ public SettingsWindow(Builder builder, ArduinoConnection arduino, Language language)
+ {
+ builder.Autoconnect(this);
+ _arduino = arduino;
+ _lang = language;
+
+ // Title = "Settings";
+ // ComPortLabel.Text = "COM Port";
+ // LogLabel.Text = "Log";
+ LogtextBox.Editable = false;
+ LogtextBox.WrapMode = WrapMode.WordChar;
+
+ RefreshButton.Clicked += (s, e) => RefreshPorts();
+ ConnectButton.Clicked += OnConnectClicked;
+ AutoConnectButton.Clicked += OnAutoConnectClicked;
+ SettingBackButton.Clicked += OnBackClicked;
+
+ // DeleteEvent += (o, a) => { a.RetVal = true; OnBackClicked(o, EventArgs.Empty); };
+
+ _arduino.Log += AppendLog;
+ _arduino.LineReceived += OnLineReceived;
+ _arduino.ConnectionChanged += OnConnectionChanged;
+
+ _lang.Register("ComPortLabel", ComPortLabel);
+ _lang.Register("LogLabel", LogLabel);
+ _lang.Register("AutoConnectButton", AutoConnectButton);
+ _lang.Register("RefreshButton", RefreshButton);
+ _lang.Register("SettingBackButton", SettingBackButton);
+ _lang.RegisterDynamic("ConnectButton", ConnectButton); // set in OnConnectionChanged
+ _lang.RegisterDynamic("StatusLabel", StatusLabel);
+ _lang.Changed += (s, e) => OnConnectionChanged(_connected);
+
+ // _buttonsById = new Dictionary
+ // {
+ // { "ConnectButton", ConnectButton },
+ // { "AutoConnectButton", AutoConnectButton },
+ // { "RefreshButton", RefreshButton },
+ // { "BackButton", BackButton },
+ // };
+
+ // // CSS ID selectors (#Option1Button etc.) need the widget Name set explicitly
+ // foreach (var kvp in _buttonsById)
+ // kvp.Value.Name = kvp.Key;
+
+ // _Language = new Language(_buttonsById);
+ // // _Language.ApplyLanguages(Language.LanguageSwitchValue);
+
+ RefreshPorts();
+ OnConnectionChanged(_arduino.IsOpen);
+ }
+
+ // ---------- UI actions ----------
+
+ private void RefreshPorts()
+ {
+ ComPortComboBox.RemoveAll();
+ _ports = ArduinoConnection.ListPorts();
+
+ foreach (string p in _ports)
+ ComPortComboBox.AppendText(p);
+
+ if (_ports.Length > 0)
+ {
+ ComPortComboBox.Active = 0;
+ AppendLog(_lang.Format("Log.PortsFound", _ports.Length));
+ }
+ else
+ {
+ AppendLog(_lang["Log.NoPorts"]);
+ }
+ }
+
+ private void OnConnectClicked(object sender, EventArgs e)
+ {
+ if (_arduino.IsOpen)
+ {
+ _arduino.Disconnect();
+ return;
+ }
+
+ string port = ComPortComboBox.ActiveText;
+ if (string.IsNullOrEmpty(port))
+ {
+ AppendLog(_lang["Log.SelectPort"]);
+ return;
+ }
+ _arduino.Connect(port);
+ }
+
+ private async void OnAutoConnectClicked(object sender, EventArgs e)
+ {
+ AutoConnectButton.Sensitive = false;
+ ConnectButton.Sensitive = false;
+ AppendLog(_lang["Log.Scanning"]);
+
+ string found = await _arduino.DetectPortAsync();
+
+ if (found != null)
+ {
+ RefreshPorts();
+ SelectPort(found);
+ _arduino.Connect(found);
+ }
+ else
+ {
+ AppendLog(_lang["Log.DeviceNotFound"]);
+ }
+
+ AutoConnectButton.Sensitive = true;
+ ConnectButton.Sensitive = true;
+ }
+
+ private void OnBackClicked(object sender, EventArgs e)
+ {
+ // Hide();
+ BackRequested?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void SelectPort(string portName)
+ {
+ for (int i = 0; i < _ports.Length; i++)
+ {
+ if (_ports[i] == portName)
+ {
+ ComPortComboBox.Active = i;
+ return;
+ }
+ }
+ }
+
+ // ---------- device events ----------
+
+ private void OnConnectionChanged(bool connected)
+ {
+ _connected = connected;
+
+ ConnectButton.Label = connected
+ ? _lang["ConnectButton.Disconnect"]
+ : _lang["ConnectButton.Connect"];
+
+ StatusLabel.Text = connected
+ ? _lang.Format("StatusLabel.Connected", _arduino.PortName)
+ : _lang["StatusLabel.Disconnected"];
+
+ ComPortComboBox.Sensitive = !connected;
+ RefreshButton.Sensitive = !connected;
+
+ StyleContext ctx = StatusLabel.StyleContext;
+ ctx.RemoveClass(connected ? "status-off" : "status-on");
+ ctx.AddClass(connected ? "status-on" : "status-off");
+ }
+
+ private void OnLineReceived(string line)
+ {
+ // Same filtering Form2.cs applied before parsing.
+ if (line.StartsWith("Send Status:")) return;
+ if (line.StartsWith("\u2713") || line.StartsWith("\u2717")) return;
+
+ if (line.Contains("ESPNOW_CONNECTED")) { AppendLog(_lang["Log.EspNowConnected"]); return; }
+ if (line.Contains("ESPNOW_DISCONNECTED")) { AppendLog(_lang["Log.EspNowDisconnected"]); return; }
+
+ AppendLog(line);
+ }
+
+ // ---------- log ----------
+
+ public void AppendLog(string message)
+ {
+ TextBuffer buf = LogtextBox.Buffer;
+
+ TextIter end = buf.EndIter;
+ buf.Insert(ref end, $"[{DateTime.Now:HH:mm:ss.fff}] {message}\n");
+
+ if (buf.LineCount > MaxLogLines)
+ {
+ TextIter start = buf.StartIter;
+ TextIter cut = buf.GetIterAtLine(buf.LineCount - MaxLogLines);
+ buf.Delete(ref start, ref cut);
+ }
+
+ TextMark mark = buf.CreateMark(null, buf.EndIter, false);
+ LogtextBox.ScrollToMark(mark, 0, false, 0, 0);
+ buf.DeleteMark(mark);
+ }
+}
\ No newline at end of file
diff --git a/program/SoundLooper.cs b/program/SoundLooper.cs
new file mode 100644
index 0000000..311d4b6
--- /dev/null
+++ b/program/SoundLooper.cs
@@ -0,0 +1,165 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Media;
+using System.Threading;
+
+public class SoundLooper : IDisposable
+{
+ // ── unix backend ──
+ private Process _proc;
+ private Thread _thread;
+ private volatile bool _running;
+ private string _path;
+
+ // ── windows backend ──
+ private SoundPlayer _player;
+
+ public event Action Log;
+
+ public bool IsPlaying { get { return _running; } }
+
+ private static bool IsWindows
+ {
+ get
+ {
+ var p = Environment.OSVersion.Platform;
+ return p != PlatformID.Unix && p != PlatformID.MacOSX;
+ }
+ }
+
+ public void Start(string wavPath)
+ {
+ Stop();
+
+ if (string.IsNullOrEmpty(wavPath) || !File.Exists(wavPath))
+ {
+ Emit("Sound file not found: " + wavPath);
+ return;
+ }
+
+ _path = wavPath;
+
+ if (IsWindows) StartWindows();
+ else StartUnix();
+ }
+
+ public void Stop()
+ {
+ if (!_running) return;
+ _running = false;
+
+ if (_player != null)
+ {
+ try { _player.Stop(); } catch { }
+ try { _player.Dispose(); } catch { }
+ _player = null;
+ }
+
+ try
+ {
+ var p = _proc;
+ if (p != null && !p.HasExited) p.Kill();
+ }
+ catch { }
+ _proc = null;
+
+ if (_thread != null && _thread.IsAlive) _thread.Join(500);
+ _thread = null;
+
+ Emit("Looping stopped");
+ }
+
+ // ── windows: SoundPlayer loops natively, no thread needed ──────────
+
+ private void StartWindows()
+ {
+ try
+ {
+ _player = new SoundPlayer(_path);
+ _player.Load(); // throws here if the WAV isn't plain PCM
+ _player.PlayLooping(); // gapless, runs until Stop()
+ _running = true;
+ Emit("Looping started (SoundPlayer): " + _path);
+ }
+ catch (Exception ex)
+ {
+ Emit("SoundPlayer failed for " + _path + ": " + ex.Message);
+ _player = null;
+ _running = false;
+ }
+ }
+
+ // ── unix: respawn a CLI player each pass ───────────────────────────
+
+ private void StartUnix()
+ {
+ _running = true;
+ _thread = new Thread(LoopWorker);
+ _thread.IsBackground = true;
+ _thread.Start();
+ Emit("Looping started (" + PlayerCommand() + "): " + _path);
+ }
+
+ private void LoopWorker()
+ {
+ while (_running)
+ {
+ try
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = PlayerCommand(),
+ Arguments = "\"" + _path + "\"",
+ UseShellExecute = false,
+ RedirectStandardError = true,
+ RedirectStandardOutput = true,
+ CreateNoWindow = true
+ };
+
+ _proc = Process.Start(psi);
+ _proc.WaitForExit();
+ }
+ catch (Exception ex)
+ {
+ Emit("Playback error: " + ex.Message);
+ _running = false;
+ return;
+ }
+ }
+ }
+
+ private static string _player_cmd;
+ private static string PlayerCommand()
+ {
+ if (_player_cmd != null) return _player_cmd;
+ _player_cmd = Exists("paplay") ? "paplay" : "aplay";
+ return _player_cmd;
+ }
+
+ private static bool Exists(string cmd)
+ {
+ try
+ {
+ var p = Process.Start(new ProcessStartInfo
+ {
+ FileName = "which",
+ Arguments = cmd,
+ UseShellExecute = false,
+ RedirectStandardOutput = true
+ });
+ p.WaitForExit();
+ return p.ExitCode == 0;
+ }
+ catch { return false; }
+ }
+
+ private void Emit(string msg)
+ {
+ var h = Log;
+ if (h != null) h(msg);
+ else Console.WriteLine("[SoundLooper] " + msg);
+ }
+
+ public void Dispose() { Stop(); }
+}
\ No newline at end of file
diff --git a/program/SoundWindow.cs b/program/SoundWindow.cs
new file mode 100644
index 0000000..e8b4488
--- /dev/null
+++ b/program/SoundWindow.cs
@@ -0,0 +1,162 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Gtk;
+using UI = Gtk.Builder.ObjectAttribute;
+
+/// Controller for notebook page 2 (SoundBox). Not a Gtk.Window — same
+/// arrangement as SettingsWindow: it drives widgets inside the shared root.
+public class SoundWindow
+{
+ [UI] private Label ConditionNameLabel = null;
+ [UI] private Grid SoundButtonGrid = null;
+ [UI] private Button SoundBackButton = null;
+
+ private const int Columns = 2;
+
+ private readonly List _allCases;
+ private readonly List _path = new List();
+
+ /// Raised when the drill-down reaches a leaf case.
+ public event EventHandler CaseSelected;
+
+ /// Raised when Back is pressed at the top level.
+ public event EventHandler BackRequested;
+
+ public SoundWindow(Builder builder)
+ {
+ builder.Autoconnect(this);
+ if (ConditionNameLabel == null || SoundButtonGrid == null || SoundBackButton == null)
+ throw new InvalidOperationException(
+ "Glade id mismatch — ConditionNameLabel=" + (ConditionNameLabel != null) +
+ " SoundButtonGrid=" + (SoundButtonGrid != null) +
+ " SoundBackButton=" + (SoundBackButton != null));
+
+ _allCases = CaseDefinition.LoadCasesFromCsv(AppFile("cases.csv"));
+ if (_allCases.Count == 0)
+ Console.WriteLine("WARNING: no cases loaded — check cases.csv");
+
+ SoundBackButton.Clicked += OnBackClicked;
+ }
+
+ /// Call every time the page becomes visible.
+ public void Reset()
+ {
+ _path.Clear();
+ Render();
+ }
+
+ /// Resolve next to the .exe, NOT the working directory.
+ public static string AppFile(string relative)
+ {
+ string dir = Path.GetDirectoryName(
+ System.Reflection.Assembly.GetExecutingAssembly().Location);
+ return Path.Combine(dir, relative);
+ }
+
+ // ── one method covers all three levels ─────────────────────────────
+
+ private void Render()
+ {
+ var matches = _allCases.Where(MatchesPath).ToList();
+
+ // Distinct values one level deeper than where we currently are.
+ var options = matches
+ .Where(c => c.TreePath.Length > _path.Count)
+ .Select(c => c.TreePath[_path.Count])
+ .Distinct()
+ .ToList();
+
+ ConditionNameLabel.Text = _path.Count == 0
+ ? "種別を選択"
+ : string.Join(" : ", _path);
+
+ BuildButtons(options, picked =>
+ {
+ _path.Add(picked);
+
+ // Landed on a leaf? Play it and stay put.
+ var leaf = _allCases.FirstOrDefault(
+ c => c.TreePath.Length == _path.Count && MatchesPath(c));
+
+ if (leaf != null)
+ {
+ Play(leaf);
+ _path.RemoveAt(_path.Count - 1);
+ return;
+ }
+
+ Render();
+ });
+ }
+
+ private bool MatchesPath(CaseDefinition c)
+ {
+ string[] p = c.TreePath;
+ if (p.Length < _path.Count) return false;
+ for (int i = 0; i < _path.Count; i++)
+ if (!string.Equals(p[i], _path[i], StringComparison.Ordinal)) return false;
+ return true;
+ }
+
+ private void BuildButtons(IEnumerable labels, Action onPick)
+ {
+ foreach (var child in SoundButtonGrid.Children)
+ {
+ SoundButtonGrid.Remove(child);
+ child.Destroy();
+ }
+
+ int i = 0;
+ foreach (string text in labels)
+ {
+ string captured = text; // don't close over the loop variable
+ var btn = new Button(captured);
+ btn.Hexpand = true;
+ btn.Clicked += (s, e) => onPick(captured);
+ SoundButtonGrid.Attach(btn, i % Columns, i / Columns, 1, 1);
+ i++;
+ }
+
+ SoundButtonGrid.ShowAll(); // widgets made in code start hidden
+ }
+
+ private void Play(CaseDefinition c)
+ {
+ if (string.IsNullOrWhiteSpace(c.SoundPath))
+ {
+ Console.WriteLine("No Sound_File for case " + c.Number);
+ return;
+ }
+
+ WavePlayer.Stop(); // the map page owns audio from here on
+
+ var h = CaseSelected;
+ if (h != null) h(this, c);
+
+ // string full = AppFile(c.SoundPath); // "sound/SND200.wav" -> absolute
+ // if (!File.Exists(full))
+ // {
+ // Console.WriteLine("Sound file missing: " + full);
+ // return;
+ // }
+
+ // ConditionNameLabel.Text = string.Join(" : ", _path);
+ // WavePlayer.Play(full);
+ }
+
+ private void OnBackClicked(object sender, EventArgs e)
+ {
+ if (_path.Count > 0)
+ {
+ _path.RemoveAt(_path.Count - 1);
+ Render();
+ }
+ else
+ {
+ WavePlayer.Stop();
+ if (BackRequested != null) BackRequested(this, EventArgs.Empty);
+ }
+ }
+}
\ No newline at end of file
diff --git a/program/WavePlayer.cs b/program/WavePlayer.cs
new file mode 100644
index 0000000..00cd07a
--- /dev/null
+++ b/program/WavePlayer.cs
@@ -0,0 +1,60 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+
+public static class WavePlayer
+{
+ private static Process _current;
+
+ private static bool IsUnix
+ {
+ get
+ {
+ int p = (int)Environment.OSVersion.Platform;
+ return p == 4 || p == 6 || p == 128;
+ }
+ }
+
+ public static void Play(string absolutePath)
+ {
+ Stop();
+
+ if (!IsUnix)
+ {
+ try
+ {
+ var sp = new System.Media.SoundPlayer(absolutePath);
+ sp.Play();
+ }
+ catch (Exception ex) { Console.WriteLine("Playback failed: " + ex.Message); }
+ return;
+ }
+
+ foreach (string player in new[] { "paplay", "aplay" })
+ {
+ try
+ {
+ var psi = new ProcessStartInfo(player, "\"" + absolutePath + "\"")
+ {
+ UseShellExecute = false,
+ RedirectStandardError = true
+ };
+ _current = Process.Start(psi);
+ return;
+ }
+ catch { /* not installed, try the next one */ }
+ }
+
+ Console.WriteLine("No audio player found — install pulseaudio-utils or alsa-utils");
+ }
+
+ public static void Stop()
+ {
+ try
+ {
+ if (_current != null && !_current.HasExited) _current.Kill();
+ }
+ catch { }
+ _current = null;
+ }
+}
\ No newline at end of file
diff --git a/program/homepage.glade b/program/homepage.glade
new file mode 100644
index 0000000..7c21a1d
--- /dev/null
+++ b/program/homepage.glade
@@ -0,0 +1,380 @@
+
+
+
+
+
+
+ False
+ 1280
+ 800
+
+
+
+ True
+ False
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ False
+ EARS
+ 1280
+ 800
+
+
+
+ True
+ False
+ 3
+ 5
+
+
+ Normal Session
+ True
+ True
+ True
+ 50
+ 50
+ True
+ True
+
+
+ 1
+ 1
+
+
+
+
+ Position Recording Mode
+ True
+ True
+ True
+ 50
+ 50
+ True
+ True
+
+
+ 1
+ 2
+
+
+
+
+ Exam Mode
+ True
+ True
+ True
+ 50
+ 50
+ True
+ True
+
+
+ 1
+ 3
+
+
+
+
+ True
+ True
+ True
+
+
+ 3
+ 0
+
+
+
+
+ True
+ False
+ Language
+
+
+ 2
+ 0
+
+
+
+
+ Close
+ True
+ True
+ True
+ True
+
+
+ 4
+ 4
+
+
+
+
+ Settings
+ True
+ True
+ True
+
+
+ 4
+ 3
+
+
+
+
+ True
+ False
+ network-offline
+ 6
+
+
+ 4
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/program/homepage.glade~ b/program/homepage.glade~
new file mode 100644
index 0000000..fade189
--- /dev/null
+++ b/program/homepage.glade~
@@ -0,0 +1,347 @@
+
+
+
+
+
+ False
+ 1280
+ 800
+
+
+
+ True
+ False
+ 3
+ 3
+ True
+
+
+ True
+ False
+ center
+ True
+ True
+ COM Port
+
+
+ 0
+ 0
+
+
+
+
+ True
+ False
+ center
+ True
+ True
+
+
+ 1
+ 0
+
+
+
+
+ Connect
+ True
+ True
+ True
+ start
+ center
+ True
+ True
+
+
+ 2
+ 0
+
+
+
+
+ Auto Connect
+ True
+ True
+ True
+ center
+ start
+ True
+ True
+
+
+ 1
+ 1
+
+
+
+
+ Back
+ True
+ True
+ True
+ end
+ end
+ True
+ True
+
+
+ 2
+ 2
+
+
+
+
+ True
+ False
+ Log
+
+
+ 0
+ 2
+
+
+
+
+ True
+ True
+ True
+ True
+ in
+
+
+ True
+ True
+ False
+ True
+
+
+
+
+ 1
+ 2
+
+
+
+
+ True
+ False
+ vertical
+
+
+ Refresh Ports
+ True
+ True
+ True
+ center
+ start
+
+
+ False
+ True
+ 0
+
+
+
+
+ True
+ False
+ Disconnected
+
+
+ False
+ True
+ 1
+
+
+
+
+ 2
+ 1
+
+
+
+
+
+
+
+
+
+ False
+
+
+
+
+
+ False
+ EARS
+ 1280
+ 800
+
+
+
+ True
+ False
+ 3
+ 5
+
+
+ Normal Session
+ True
+ True
+ True
+ 50
+ 50
+ True
+ True
+
+
+ 1
+ 1
+
+
+
+
+ Position Recording Mode
+ True
+ True
+ True
+ 50
+ 50
+ True
+ True
+
+
+ 1
+ 2
+
+
+
+
+ Exam Mode
+ True
+ True
+ True
+ 50
+ 50
+ True
+ True
+
+
+ 1
+ 3
+
+
+
+
+ True
+ True
+ True
+
+
+ 3
+ 0
+
+
+
+
+ True
+ False
+ Language
+
+
+ 2
+ 0
+
+
+
+
+ Close
+ True
+ True
+ True
+ True
+
+
+ 4
+ 4
+
+
+
+
+ Settings
+ True
+ True
+ True
+
+
+ 4
+ 3
+
+
+
+
+ True
+ False
+ network-offline
+ 6
+
+
+ 4
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/program/homepageall.glade b/program/homepageall.glade
new file mode 100644
index 0000000..03f30c2
--- /dev/null
+++ b/program/homepageall.glade
@@ -0,0 +1,487 @@
+
+
+
+
+
+ False
+
+
+ True
+ True
+ False
+ False
+
+
+
+ True
+ False
+ 3
+ 5
+
+
+ Normal Session
+ True
+ True
+ True
+ 50
+ 50
+ 50
+ 50
+ True
+ True
+
+
+ 1
+ 1
+
+
+
+
+ Position Recording Mode
+ True
+ True
+ True
+ 50
+ 50
+ 50
+ 50
+ True
+ True
+
+
+ 1
+ 2
+
+
+
+
+ Exam Mode
+ True
+ True
+ True
+ 50
+ 50
+ 50
+ 50
+ True
+ True
+
+
+ 1
+ 3
+
+
+
+
+ A / あ
+ True
+ True
+ True
+ True
+
+
+ 3
+ 0
+
+
+
+
+ True
+ False
+ Language
+
+
+ 2
+ 0
+
+
+
+
+ Close
+ True
+ True
+ True
+ True
+
+
+ 4
+ 4
+
+
+
+
+ Settings
+ True
+ True
+ True
+
+
+ 4
+ 3
+
+
+
+
+ True
+ False
+ network-offline
+ 6
+
+
+ 4
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ True
+ False
+ 3
+ 3
+ True
+
+
+ True
+ False
+ center
+ True
+ True
+ COM Port
+
+
+ 0
+ 0
+
+
+
+
+ True
+ False
+ center
+ True
+ True
+
+
+ 1
+ 0
+
+
+
+
+ Connect
+ True
+ True
+ True
+ start
+ center
+ True
+ True
+
+
+ 2
+ 0
+
+
+
+
+ Auto Connect
+ True
+ True
+ True
+ center
+ start
+ True
+ True
+
+
+ 1
+ 1
+
+
+
+
+ Back
+ True
+ True
+ True
+ end
+ end
+ True
+ True
+
+
+ 2
+ 2
+
+
+
+
+ True
+ False
+ Log
+
+
+ 0
+ 2
+
+
+
+
+ True
+ True
+ True
+ True
+ in
+
+
+ True
+ True
+ False
+ True
+
+
+
+
+ 1
+ 2
+
+
+
+
+ True
+ False
+ vertical
+
+
+ Refresh Ports
+ True
+ True
+ True
+ center
+ start
+
+
+ False
+ True
+ 0
+
+
+
+
+ True
+ False
+ Disconnected
+
+
+ False
+ True
+ 1
+
+
+
+
+ 2
+ 1
+
+
+
+
+
+
+
+ 1
+
+
+
+
+
+
+
+
+
+ False
+ 1280
+ 800
+
+
+ True
+ False
+ vertical
+
+
+ True
+ False
+ label
+
+
+
+
+
+ False
+ True
+ 0
+
+
+
+
+
+ True
+ False
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ False
+ True
+ 1
+
+
+
+
+
+ True
+ False
+
+
+ Back
+ True
+ True
+ True
+ end
+ True
+ right
+
+
+ 2
+ 0
+
+
+
+
+
+
+
+
+
+
+ False
+ True
+ 2
+
+
+
+
+
+
diff --git a/program/homepageallv3.glade b/program/homepageallv3.glade
new file mode 100644
index 0000000..284cfee
--- /dev/null
+++ b/program/homepageallv3.glade
@@ -0,0 +1,445 @@
+
+
+
+
+
+ False
+
+
+ True
+ True
+ False
+ False
+
+
+
+ True
+ False
+ 3
+ 5
+
+
+ Normal Session
+ True
+ True
+ True
+ 50
+ 50
+ 50
+ 50
+ True
+ True
+
+
+ 1
+ 1
+
+
+
+
+ Position Recording Mode
+ True
+ True
+ True
+ 50
+ 50
+ 50
+ 50
+ True
+ True
+
+
+ 1
+ 2
+
+
+
+
+ Exam Mode
+ True
+ True
+ True
+ 50
+ 50
+ 50
+ 50
+ True
+ True
+
+
+ 1
+ 3
+
+
+
+
+ A / あ
+ True
+ True
+ True
+ True
+
+
+ 3
+ 0
+
+
+
+
+ True
+ False
+ Language
+
+
+ 2
+ 0
+
+
+
+
+ Close
+ True
+ True
+ True
+ True
+
+
+ 4
+ 4
+
+
+
+
+ Settings
+ True
+ True
+ True
+
+
+ 4
+ 3
+
+
+
+
+ True
+ False
+ network-offline
+ 6
+
+
+ 4
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ True
+ False
+ 3
+ 3
+ True
+
+
+ True
+ False
+ center
+ True
+ True
+ COM Port
+
+
+ 0
+ 0
+
+
+
+
+ True
+ False
+ center
+ True
+ True
+
+
+ 1
+ 0
+
+
+
+
+ Connect
+ True
+ True
+ True
+ start
+ center
+ True
+ True
+
+
+ 2
+ 0
+
+
+
+
+ Auto Connect
+ True
+ True
+ True
+ center
+ start
+ True
+ True
+
+
+ 1
+ 1
+
+
+
+
+ Back
+ True
+ True
+ True
+ end
+ end
+ True
+ True
+
+
+ 2
+ 2
+
+
+
+
+ True
+ False
+ Log
+
+
+ 0
+ 2
+
+
+
+
+ True
+ True
+ True
+ True
+ in
+
+
+ True
+ True
+ False
+ True
+
+
+
+
+ 1
+ 2
+
+
+
+
+ True
+ False
+ vertical
+
+
+ Refresh Ports
+ True
+ True
+ True
+ center
+ start
+
+
+ False
+ True
+ 0
+
+
+
+
+ True
+ False
+ Disconnected
+
+
+ False
+ True
+ 1
+
+
+
+
+ 2
+ 1
+
+
+
+
+
+
+
+ 1
+
+
+
+
+ True
+ False
+ vertical
+
+
+ True
+ False
+ label
+
+
+
+
+
+ False
+ True
+ 0
+
+
+
+
+ True
+ True
+ False
+ False
+ never
+ in
+
+
+ True
+ False
+
+
+
+ True
+ False
+ 6
+ 6
+ True
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ True
+ True
+ 1
+
+
+
+
+ Back
+ True
+ True
+ True
+ end
+ True
+ right
+
+
+ False
+ True
+ 4
+
+
+
+
+ 2
+
+
+
+
+
+
+
+
+
diff --git a/program/homepageallv4.glade b/program/homepageallv4.glade
new file mode 100644
index 0000000..6f3fd9c
--- /dev/null
+++ b/program/homepageallv4.glade
@@ -0,0 +1,472 @@
+
+
+
+
+
+ False
+
+
+
+ True
+ False
+
+
+ True
+ False
+ True
+ True
+ bodyImage
+ True
+
+
+ 1
+ 0
+
+
+
+
+
+
+
+
+
+ False
+
+
+ True
+ True
+ False
+ False
+
+
+
+ True
+ False
+ 3
+ 5
+
+
+ Normal Session
+ True
+ True
+ True
+ 50
+ 50
+ 50
+ 50
+ True
+ True
+
+
+ 1
+ 1
+
+
+
+
+ Position Recording Mode
+ True
+ True
+ True
+ 50
+ 50
+ 50
+ 50
+ True
+ True
+
+
+ 1
+ 2
+
+
+
+
+ Exam Mode
+ True
+ True
+ True
+ 50
+ 50
+ 50
+ 50
+ True
+ True
+
+
+ 1
+ 3
+
+
+
+
+ A / あ
+ True
+ True
+ True
+ True
+
+
+ 3
+ 0
+
+
+
+
+ True
+ False
+ Language
+
+
+ 2
+ 0
+
+
+
+
+ Close
+ True
+ True
+ True
+ True
+
+
+ 4
+ 4
+
+
+
+
+ Settings
+ True
+ True
+ True
+
+
+ 4
+ 3
+
+
+
+
+ True
+ False
+ network-offline
+ 6
+
+
+ 4
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ True
+ False
+ 3
+ 3
+ True
+
+
+ True
+ False
+ center
+ True
+ True
+ COM Port
+
+
+ 0
+ 0
+
+
+
+
+ True
+ False
+ center
+ True
+ True
+
+
+ 1
+ 0
+
+
+
+
+ Connect
+ True
+ True
+ True
+ start
+ center
+ True
+ True
+
+
+ 2
+ 0
+
+
+
+
+ Auto Connect
+ True
+ True
+ True
+ center
+ start
+ True
+ True
+
+
+ 1
+ 1
+
+
+
+
+ Back
+ True
+ True
+ True
+ end
+ end
+ True
+ True
+
+
+ 2
+ 2
+
+
+
+
+ True
+ False
+ Log
+
+
+ 0
+ 2
+
+
+
+
+ True
+ True
+ True
+ True
+ in
+
+
+ True
+ True
+ False
+ True
+
+
+
+
+ 1
+ 2
+
+
+
+
+ True
+ False
+ vertical
+
+
+ Refresh Ports
+ True
+ True
+ True
+ center
+ start
+
+
+ False
+ True
+ 0
+
+
+
+
+ True
+ False
+ Disconnected
+
+
+ False
+ True
+ 1
+
+
+
+
+ 2
+ 1
+
+
+
+
+
+
+
+ 1
+
+
+
+
+ True
+ False
+ vertical
+
+
+ True
+ False
+ label
+
+
+
+
+
+ False
+ True
+ 0
+
+
+
+
+ True
+ True
+ False
+ False
+ never
+ in
+
+
+ True
+ False
+
+
+
+ True
+ False
+ 6
+ 6
+ True
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ True
+ True
+ 1
+
+
+
+
+ Back
+ True
+ True
+ True
+ end
+ True
+ right
+
+
+ False
+ True
+ 4
+
+
+
+
+ 2
+
+
+
+
+
+
+
+
+
diff --git a/program/homepageallv5.glade b/program/homepageallv5.glade
new file mode 100644
index 0000000..dd9fd65
--- /dev/null
+++ b/program/homepageallv5.glade
@@ -0,0 +1,499 @@
+
+
+
+
+
+ False
+
+
+ True
+ True
+ False
+ False
+
+
+
+ True
+ False
+ 3
+ 5
+
+
+ Normal Session
+ True
+ True
+ True
+ 50
+ 50
+ 50
+ 50
+ True
+ True
+
+
+ 1
+ 1
+
+
+
+
+ Position Recording Mode
+ True
+ True
+ True
+ 50
+ 50
+ 50
+ 50
+ True
+ True
+
+
+ 1
+ 2
+
+
+
+
+ Exam Mode
+ True
+ True
+ True
+ 50
+ 50
+ 50
+ 50
+ True
+ True
+
+
+ 1
+ 3
+
+
+
+
+ A / あ
+ True
+ True
+ True
+ True
+
+
+ 3
+ 0
+
+
+
+
+ True
+ False
+ Language
+
+
+ 2
+ 0
+
+
+
+
+ Close
+ True
+ True
+ True
+ True
+
+
+ 4
+ 4
+
+
+
+
+ Settings
+ True
+ True
+ True
+
+
+ 4
+ 3
+
+
+
+
+ True
+ False
+ network-offline
+ 6
+
+
+ 4
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ True
+ False
+ 3
+ 3
+ True
+
+
+ True
+ False
+ center
+ True
+ True
+ COM Port
+
+
+ 0
+ 0
+
+
+
+
+ True
+ False
+ center
+ True
+ True
+
+
+ 1
+ 0
+
+
+
+
+ Connect
+ True
+ True
+ True
+ start
+ center
+ True
+ True
+
+
+ 2
+ 0
+
+
+
+
+ Auto Connect
+ True
+ True
+ True
+ center
+ start
+ True
+ True
+
+
+ 1
+ 1
+
+
+
+
+ Back
+ True
+ True
+ True
+ end
+ end
+ True
+ True
+
+
+ 2
+ 2
+
+
+
+
+ True
+ False
+ Log
+
+
+ 0
+ 2
+
+
+
+
+ True
+ True
+ True
+ True
+ in
+
+
+ True
+ True
+ False
+ True
+
+
+
+
+ 1
+ 2
+
+
+
+
+ True
+ False
+ vertical
+
+
+ Refresh Ports
+ True
+ True
+ True
+ center
+ start
+
+
+ False
+ True
+ 0
+
+
+
+
+ True
+ False
+ Disconnected
+
+
+ False
+ True
+ 1
+
+
+
+
+ 2
+ 1
+
+
+
+
+
+
+
+ 1
+
+
+
+
+ True
+ False
+ vertical
+
+
+ True
+ False
+ label
+
+
+
+
+
+ False
+ True
+ 0
+
+
+
+
+ True
+ True
+ False
+ False
+ never
+ in
+
+
+ True
+ False
+
+
+
+ True
+ False
+ 6
+ 6
+ True
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ True
+ True
+ 1
+
+
+
+
+ Back
+ True
+ True
+ True
+ end
+ True
+ right
+
+
+ False
+ True
+ 4
+
+
+
+
+ 2
+
+
+
+
+ True
+ False
+ vertical
+ 6
+
+
+ True
+ False
+ label
+
+
+
+
+
+ False
+ True
+ 0
+
+
+
+
+ True
+ False
+ True
+ True
+
+
+ True
+ True
+ 1
+
+
+
+
+ Back
+ True
+ True
+ True
+ end
+ True
+
+
+ False
+ True
+ 2
+
+
+
+
+ 3
+
+
+
+
+
+
+
+
+
diff --git a/program/homepageallv6.glade b/program/homepageallv6.glade
new file mode 100644
index 0000000..4630bf4
--- /dev/null
+++ b/program/homepageallv6.glade
@@ -0,0 +1,828 @@
+
+
+
+
+
+ False
+
+
+ True
+ True
+ False
+ False
+
+
+
+ True
+ False
+ 3
+ 5
+
+
+ Normal Session
+ True
+ True
+ True
+ 50
+ 50
+ 50
+ 50
+ True
+ True
+
+
+ 1
+ 1
+
+
+
+
+ Position Recording Mode
+ True
+ True
+ True
+ 50
+ 50
+ 50
+ 50
+ True
+ True
+
+
+ 1
+ 2
+
+
+
+
+ Exam Mode
+ True
+ True
+ True
+ 50
+ 50
+ 50
+ 50
+ True
+ True
+
+
+ 1
+ 3
+
+
+
+
+ A / あ
+ True
+ True
+ True
+ True
+
+
+ 3
+ 0
+
+
+
+
+ True
+ False
+ Language
+
+
+ 2
+ 0
+
+
+
+
+ Close
+ True
+ True
+ True
+ True
+
+
+ 4
+ 4
+
+
+
+
+ Settings
+ True
+ True
+ True
+
+
+ 4
+ 3
+
+
+
+
+ True
+ False
+ network-offline
+ 6
+
+
+ 4
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ True
+ False
+ 3
+ 3
+ True
+
+
+ True
+ False
+ center
+ True
+ True
+ COM Port
+
+
+ 0
+ 0
+
+
+
+
+ True
+ False
+ center
+ True
+ True
+
+
+ 1
+ 0
+
+
+
+
+ Connect
+ True
+ True
+ True
+ start
+ center
+ True
+ True
+
+
+ 2
+ 0
+
+
+
+
+ Auto Connect
+ True
+ True
+ True
+ center
+ start
+ True
+ True
+
+
+ 1
+ 1
+
+
+
+
+ Back
+ True
+ True
+ True
+ end
+ end
+ True
+ True
+
+
+ 2
+ 2
+
+
+
+
+ True
+ False
+ Log
+
+
+ 0
+ 2
+
+
+
+
+ True
+ True
+ True
+ True
+ in
+
+
+ True
+ True
+ False
+ True
+
+
+
+
+ 1
+ 2
+
+
+
+
+ True
+ False
+ vertical
+
+
+ Refresh Ports
+ True
+ True
+ True
+ center
+ start
+
+
+ False
+ True
+ 0
+
+
+
+
+ True
+ False
+ Disconnected
+
+
+ False
+ True
+ 1
+
+
+
+
+ 2
+ 1
+
+
+
+
+
+
+
+ 1
+
+
+
+
+ True
+ False
+ vertical
+
+
+ True
+ False
+ label
+
+
+
+
+
+ False
+ True
+ 0
+
+
+
+
+ True
+ True
+ False
+ False
+ never
+ in
+
+
+ True
+ False
+
+
+
+ True
+ False
+ 6
+ 6
+ True
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ True
+ True
+ 1
+
+
+
+
+ Back
+ True
+ True
+ True
+ end
+ True
+ right
+
+
+ False
+ True
+ 4
+
+
+
+
+ 2
+
+
+
+
+ True
+ False
+ horizontal
+ 12
+
+
+ True
+ True
+ 340
+ never
+ none
+
+
+ True
+ False
+
+
+ True
+ False
+ vertical
+ 10
+ 12
+ 12
+ 12
+ 12
+
+
+ True
+ False
+ 0
+ in
+
+
+ True
+ False
+ -
+ True
+ 0
+ 8
+ 8
+ 6
+ 6
+
+
+
+
+ True
+ False
+ 選択中の症例
+
+
+
+
+ False
+ True
+ 0
+
+
+
+
+ True
+ False
+ 0
+ in
+
+
+ True
+ False
+ vertical
+ 8
+ 8
+ 6
+ 6
+
+
+ スケルトン
+ True
+ True
+ False
+ True
+ True
+
+
+ False
+ True
+ 0
+
+
+
+
+ ジャケット
+ True
+ True
+ False
+ True
+ BodySkeletonRadio
+
+
+ False
+ True
+ 1
+
+
+
+
+
+
+ True
+ False
+ 体の画像選択
+
+
+
+
+ False
+ True
+ 1
+
+
+
+
+ True
+ False
+ 8
+
+
+ True
+ False
+ サイズ:
+
+
+ False
+ True
+ 0
+
+
+
+
+ True
+ False
+ 0
+
+ - L
+ - XL
+
+
+
+ False
+ True
+ 1
+
+
+
+
+ False
+ True
+ 2
+
+
+
+
+ True
+ False
+ 0
+ in
+
+
+ True
+ False
+ True
+ 8
+ 8
+ 6
+ 6
+
+
+ 前
+ True
+ True
+ False
+ True
+ False
+
+
+ True
+ True
+ 0
+
+
+
+
+ 後
+ True
+ True
+ False
+ False
+ ViewFrontRadio
+
+
+ True
+ True
+ 1
+
+
+
+
+ 右
+ True
+ True
+ False
+ False
+ ViewFrontRadio
+
+
+ True
+ True
+ 2
+
+
+
+
+ 左
+ True
+ True
+ False
+ False
+ ViewFrontRadio
+
+
+ True
+ True
+ 3
+
+
+
+
+
+
+ True
+ False
+ 視点
+
+
+
+
+ False
+ True
+ 3
+
+
+
+
+ 番号を隠す
+ True
+ True
+ False
+ True
+ True
+
+
+ False
+ True
+ 4
+
+
+
+
+ 黒画素でも再生
+ True
+ True
+ False
+ True
+
+
+ False
+ True
+ 5
+
+
+
+
+ 再生テスト
+ True
+ True
+ True
+
+
+ False
+ True
+ 6
+
+
+
+
+ True
+ False
+ vertical
+ 8
+ True
+
+
+ True
+ True
+ 7
+
+
+
+
+ 戻る
+ True
+ True
+ True
+ end
+
+
+ False
+ True
+ 8
+
+
+
+
+
+
+
+
+ False
+ True
+ 0
+
+
+
+
+ True
+ False
+ vertical
+ 6
+ True
+ 12
+ 12
+ 12
+
+
+ True
+ False
+ label
+
+
+
+
+
+ False
+ True
+ 0
+
+
+
+
+ True
+ False
+ True
+ True
+
+
+ True
+ True
+ 1
+
+
+
+
+ True
+ True
+ 1
+
+
+
+
+ 3
+
+
+
+
+
+
+
+
+
diff --git a/program/info_eng.csv b/program/info_eng.csv
new file mode 100644
index 0000000..0335a1a
--- /dev/null
+++ b/program/info_eng.csv
@@ -0,0 +1,26 @@
+key,text
+Option1Button,Normal Session
+Option2Button,Position Recording Mode
+Option3Button,Exam Mode
+SettingButton,Settings
+CloseButton,Close
+LanguageLabel,Language
+LanguageToggle,あ
+ComPortLabel,COM Port
+LogLabel,Log
+AutoConnectButton,Auto Connect
+RefreshButton,Refresh Ports
+SettingBackButton,Back
+ConnectButton.Connect,Connect
+ConnectButton.Disconnect,Disconnect
+StatusLabel.Connected,Connected ({0})
+StatusLabel.Disconnected,Disconnected
+ConnectionIcon.Connected,Connected to {0}
+ConnectionIcon.Disconnected,Device not connected
+Log.PortsFound,{0} port(s) found
+Log.NoPorts,No serial ports found
+Log.SelectPort,Select a COM port first
+Log.Scanning,Scanning ports for device...
+Log.DeviceNotFound,Device not found — use manual connection
+Log.EspNowConnected,[ESP-NOW] Peer connected
+Log.EspNowDisconnected,[ESP-NOW] Peer disconnected
\ No newline at end of file
diff --git a/program/info_jpn.csv b/program/info_jpn.csv
new file mode 100644
index 0000000..eb576d7
--- /dev/null
+++ b/program/info_jpn.csv
@@ -0,0 +1,26 @@
+key,text
+Option1Button,通常セッション
+Option2Button,位置記録モード
+Option3Button,試験モード
+SettingButton,設定
+CloseButton,終了
+LanguageLabel,言語
+LanguageToggle,A
+ComPortLabel,COMポート
+LogLabel,ログ
+AutoConnectButton,自動接続
+RefreshButton,ポート更新
+SettingBackButton,戻る
+ConnectButton.Connect,接続
+ConnectButton.Disconnect,切断
+StatusLabel.Connected,接続済み ({0})
+StatusLabel.Disconnected,未接続
+ConnectionIcon.Connected,{0} に接続しました
+ConnectionIcon.Disconnected,デバイスが接続されていません
+Log.PortsFound,ポートが {0} 個見つかりました
+Log.NoPorts,シリアルポートが見つかりません
+Log.SelectPort,COMポートを選択してください
+Log.Scanning,デバイスを検索中...
+Log.DeviceNotFound,デバイスが見つかりません — 手動で接続してください
+Log.EspNowConnected,[ESP-NOW] 相手が接続しました
+Log.EspNowDisconnected,[ESP-NOW] 相手が切断しました
\ No newline at end of file
diff --git a/program/lang_eng.css b/program/lang_eng.css
new file mode 100644
index 0000000..fcf83ec
--- /dev/null
+++ b/program/lang_eng.css
@@ -0,0 +1,69 @@
+#Option1Button {
+ font-family: 'Sans';
+ font-size: 20px;
+ font-weight: normal;
+ font-style: normal;
+}
+
+#Option2Button {
+ font-family: 'Sans';
+ font-size: 20px;
+ font-weight: normal;
+ font-style: normal;
+}
+
+#Option3Button {
+ font-family: 'Sans';
+ font-size: 20px;
+ font-weight: normal;
+ font-style: normal;
+}
+
+#CloseButton {
+ font-family: 'Sans';
+ font-size: 18px;
+ font-weight: bold;
+ font-style: normal;
+}
+
+#StatusLabel.status-on { color: #2e7d32; font-weight: bold; }
+#StatusLabel.status-off { color: #c62828; font-weight: bold; }
+
+/* Setting Window
+#ConnectButton {
+ font-family: 'Sans';
+ font-size: 20px;
+ font-weight: normal;
+ font-style: normal;
+}
+
+#AutoConnectButton {
+ font-family: 'Sans';
+ font-size: 20px;
+ font-weight: normal;
+ font-style: normal;
+}
+
+#Option3Button {
+ font-family: 'Sans';
+ font-size: 20px;
+ font-weight: normal;
+ font-style: normal;
+}
+
+#CloseButton {
+ font-family: 'Sans';
+ font-size: 18px;
+ font-weight: bold;
+ font-style: normal;
+} */
+
+/* [UI] private Label ComPortLabel = null;
+ [UI] private ComboBoxText ComPortComboBox = null;
+ [UI] private Button ConnectButton = null;
+ [UI] private Button AutoConnectButton = null;
+ [UI] private Button RefreshButton = null;
+ [UI] private Button BackButton = null;
+ [UI] private Label StatusLabel = null;
+ [UI] private Label LogLabel = null;
+ [UI] private TextView LogtextBox = null; */
\ No newline at end of file
diff --git a/program/lang_jpn.css b/program/lang_jpn.css
new file mode 100644
index 0000000..13b87e2
--- /dev/null
+++ b/program/lang_jpn.css
@@ -0,0 +1,30 @@
+#Option1Button {
+ font-family: 'Noto Sans JP';
+ font-size: 20px;
+ font-weight: normal;
+ font-style: normal;
+}
+
+#Option2Button {
+ font-family: 'Noto Sans JP';
+ font-size: 20px;
+ font-weight: normal;
+ font-style: normal;
+}
+
+#Option3Button {
+ font-family: 'Noto Sans JP';
+ font-size: 20px;
+ font-weight: normal;
+ font-style: normal;
+}
+
+#CloseButton {
+ font-family: 'Noto Sans JP';
+ font-size: 18px;
+ font-weight: bold;
+ font-style: normal;
+}
+
+#StatusLabel.status-on { color: #2e7d32; font-weight: bold; }
+#StatusLabel.status-off { color: #c62828; font-weight: bold; }
\ No newline at end of file
diff --git a/program/program.cs b/program/program.cs
new file mode 100644
index 0000000..f9db539
--- /dev/null
+++ b/program/program.cs
@@ -0,0 +1,16 @@
+using System;
+using Gtk;
+
+class Program
+{
+ [STAThread]
+ static void Main(string[] args)
+ {
+ Application.Init();
+
+ var app = new MainWindow();
+ app.ShowAll();
+
+ Application.Run();
+ }
+}
\ No newline at end of file
diff --git a/program/style.css b/program/style.css
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/program/style.css
diff --git a/programdeltafixer/CalibrationWindow.cs b/programdeltafixer/CalibrationWindow.cs
new file mode 100644
index 0000000..e2bf735
--- /dev/null
+++ b/programdeltafixer/CalibrationWindow.cs
@@ -0,0 +1,600 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Text;
+using Gtk;
+using UI = Gtk.Builder.ObjectAttribute;
+using IOPath = System.IO.Path;
+
+/// Standalone tuning tool: circle cluster scaling, sound-map zoom,
+/// and sound-map position over the jacket. Writes values you paste
+/// back into MapWindow.cs.
+public class CalibrationWindow : Window
+{
+ [UI] private Box ControlBox = null;
+ [UI] private DrawingArea CalibArea = null;
+ [UI] private TextView OutputView = null;
+ [UI] private Button DumpButton = null;
+ [UI] private Button SaveButton = null;
+ [UI] private Button ResetButton = null;
+
+ // ── per (size, view) tunables ──
+ private class Tune
+ {
+ public double OffsetX = 25.0; // circle cluster shift (subtracted)
+ public double OffsetY = 50.0;
+ public double SizeScale = 1.0; // circle diameter multiplier
+ public double Cluster = 1.0; // 1.0 = no clustering
+ public double MapZoom = 1.0; // fraction of body the map fills
+ public double MapX = 0.0; // map nudge, body pixels
+ public double MapY = 0.0;
+ public double MapAlpha = 0.7;
+ public double FitMargin = 40.0; // Form2: panel - 40 (front: -100)
+
+ public Tune Clone() { return (Tune)MemberwiseClone(); }
+ }
+
+ private readonly Dictionary _tunes = new Dictionary();
+ private readonly Dictionary _spins = new Dictionary();
+
+ private ComboBoxText _viewCombo, _sizeCombo, _bodyCombo, _caseCombo;
+ private CheckButton _showCircles, _showMap, _showBody, _showGuides;
+
+ private JacketData _jacket;
+ private List _cases;
+
+ private Gdk.Pixbuf _body, _map;
+ private string _view = "front";
+ private string _size = "L";
+ private bool _skeleton = true;
+ private bool _building; // suppress handlers while repopulating spins
+
+ private const int CircleDiameter = 50;
+ private const string CalibFile = "calib.txt";
+
+ private string Key { get { return _size + ":" + _view; } }
+ private bool _listed;
+ private Tune T
+ {
+ get
+ {
+ if (!_tunes.ContainsKey(Key)) _tunes[Key] = DefaultFor(_view);
+ return _tunes[Key];
+ }
+ }
+
+ private static Tune DefaultFor(string view)
+ {
+ var t = new Tune();
+ switch (view)
+ {
+ case "back": t.OffsetX = 20; t.OffsetY = 20; t.SizeScale = 0.5; break;
+ case "right": t.OffsetX = 10; t.OffsetY = -150; t.Cluster = 0.5; break;
+ case "left": t.OffsetX = -50; t.OffsetY = -150; t.Cluster = 0.4; break;
+ default: t.OffsetX = 25; t.OffsetY = 50; t.FitMargin = 100; break;
+ }
+ return t;
+ }
+
+ // ── construction ───────────────────────────────────────────────────
+
+ public CalibrationWindow() : this(CreateBuilder()) { }
+
+ private static Builder CreateBuilder()
+ {
+ var b = new Builder();
+ b.AddFromFile("calibrate.glade");
+ return b;
+ }
+
+ private CalibrationWindow(Builder builder) : base(builder.GetObject("CalibRoot").Handle)
+ {
+ builder.Autoconnect(this);
+
+ _jacket = JacketData.Load();
+ _cases = CaseDefinition.LoadCasesFromCsv("cases.csv");
+
+ BuildControls();
+ LoadCalib();
+
+ CalibArea.Drawn += OnDraw;
+ DumpButton.Clicked += (s, e) => Emit(DumpConstants());
+ SaveButton.Clicked += (s, e) => SaveCalib();
+ ResetButton.Clicked += (s, e) => { _tunes[Key] = DefaultFor(_view); SyncSpins(); Redraw(); };
+
+ DeleteEvent += (o, a) => Application.Quit();
+
+ ReloadImages();
+ SyncSpins();
+ }
+
+ private void BuildControls()
+ {
+ ControlBox.PackStart(Header("Source"), false, false, 0);
+
+ _viewCombo = Combo(new[] { "front", "back", "right", "left" }, 0, v =>
+ {
+ _view = v; ReloadImages(); SyncSpins(); Redraw();
+ });
+ ControlBox.PackStart(Row("View", _viewCombo), false, false, 0);
+
+ _sizeCombo = Combo(new[] { "L", "XL" }, 0, v =>
+ {
+ _size = v; SyncSpins(); Redraw();
+ });
+ ControlBox.PackStart(Row("Jacket size", _sizeCombo), false, false, 0);
+
+ _bodyCombo = Combo(new[] { "Skeleton", "Jacket" }, 0, v =>
+ {
+ _skeleton = (v == "Skeleton"); ReloadImages(); Redraw();
+ });
+ ControlBox.PackStart(Row("Body", _bodyCombo), false, false, 0);
+
+ _caseCombo = new ComboBoxText();
+ foreach (var c in _cases)
+ _caseCombo.AppendText(c.Number + " " + string.Join(":", c.TreePath));
+ if (_cases.Count > 0) _caseCombo.Active = 0;
+ _caseCombo.Changed += (s, e) => { ReloadImages(); Redraw(); };
+ ControlBox.PackStart(Row("Case", _caseCombo), false, false, 0);
+
+ ControlBox.PackStart(Header("Sound map"), false, false, 0);
+ AddSpin("MapZoom", "Zoom (x body)", 0.10, 3.00, 0.01, 2);
+ AddSpin("MapX", "Offset X (px)", -600, 600, 1, 0);
+ AddSpin("MapY", "Offset Y (px)", -600, 600, 1, 0);
+ AddSpin("MapAlpha", "Opacity", 0.00, 1.00, 0.05, 2);
+
+ ControlBox.PackStart(Header("Circle cluster"), false, false, 0);
+ AddSpin("OffsetX", "Offset X (px)", -600, 600, 1, 0);
+ AddSpin("OffsetY", "Offset Y (px)", -600, 600, 1, 0);
+ AddSpin("SizeScale", "Circle size", 0.05, 4.00, 0.05, 2);
+ AddSpin("Cluster", "Cluster factor", 0.05, 2.00, 0.05, 2);
+ AddSpin("FitMargin", "Fit margin (px)", 0, 400, 5, 0);
+
+ ControlBox.PackStart(Header("Display"), false, false, 0);
+ _showBody = Check("Show body", true);
+ _showMap = Check("Show map", true);
+ _showCircles = Check("Show circles", true);
+ _showGuides = Check("Show centre guides", true);
+ }
+
+ // ── widget helpers ─────────────────────────────────────────────────
+
+ private Label Header(string text)
+ {
+ var l = new Label();
+ l.Markup = "" + text + "";
+ l.Xalign = 0;
+ l.MarginTop = 8;
+ return l;
+ }
+
+ private Box Row(string label, Widget w)
+ {
+ var box = new Box(Orientation.Horizontal, 6);
+ var l = new Label(label);
+ l.Xalign = 0;
+ l.WidthRequest = 130;
+ box.PackStart(l, false, false, 0);
+ box.PackStart(w, true, true, 0);
+ return box;
+ }
+
+ private ComboBoxText Combo(string[] items, int active, Action onChange)
+ {
+ var c = new ComboBoxText();
+ foreach (string s in items) c.AppendText(s);
+ c.Active = active;
+ c.Changed += (s, e) => { if (!_building && c.ActiveText != null) onChange(c.ActiveText); };
+ return c;
+ }
+
+ private CheckButton Check(string label, bool active)
+ {
+ var c = new CheckButton(label);
+ c.Active = active;
+ c.Toggled += (s, e) => Redraw();
+ ControlBox.PackStart(c, false, false, 0);
+ return c;
+ }
+
+ private void AddSpin(string field, string label, double min, double max, double step, uint digits)
+ {
+ var sb = new SpinButton(min, max, step);
+ sb.Digits = digits;
+ sb.Numeric = true;
+ sb.ValueChanged += (s, e) =>
+ {
+ if (_building) return;
+ Set(field, sb.Value);
+ Redraw();
+ };
+ _spins[field] = sb;
+ ControlBox.PackStart(Row(label, sb), false, false, 0);
+ }
+
+ private void Set(string field, double v)
+ {
+ var t = T;
+ switch (field)
+ {
+ case "OffsetX": t.OffsetX = v; break;
+ case "OffsetY": t.OffsetY = v; break;
+ case "SizeScale": t.SizeScale = v; break;
+ case "Cluster": t.Cluster = v; break;
+ case "MapZoom": t.MapZoom = v; break;
+ case "MapX": t.MapX = v; break;
+ case "MapY": t.MapY = v; break;
+ case "MapAlpha": t.MapAlpha = v; break;
+ case "FitMargin": t.FitMargin = v; break;
+ }
+ }
+
+ private double Get(string field)
+ {
+ var t = T;
+ switch (field)
+ {
+ case "OffsetX": return t.OffsetX;
+ case "OffsetY": return t.OffsetY;
+ case "SizeScale": return t.SizeScale;
+ case "Cluster": return t.Cluster;
+ case "MapZoom": return t.MapZoom;
+ case "MapX": return t.MapX;
+ case "MapY": return t.MapY;
+ case "MapAlpha": return t.MapAlpha;
+ case "FitMargin": return t.FitMargin;
+ }
+ return 0;
+ }
+
+ private void SyncSpins()
+ {
+ _building = true;
+ foreach (var kv in _spins) kv.Value.Value = Get(kv.Key);
+ _building = false;
+ }
+
+ private void Redraw() { CalibArea.QueueDraw(); }
+
+ // ── images ─────────────────────────────────────────────────────────
+
+ private void ReloadImages()
+ {
+ if (_body != null) { _body.Dispose(); _body = null; }
+ if (_map != null) { _map.Dispose(); _map = null; }
+
+ _body = LoadBody();
+ _map = LoadMap();
+ }
+
+ private Gdk.Pixbuf LoadBody()
+ {
+ string stem;
+ if (_skeleton)
+ {
+ switch (_view)
+ {
+ case "back": stem = "jacketBackBody"; break;
+ case "right": stem = "jacketRightBody"; break;
+ case "left": stem = "jacketLeftBody"; break;
+ default: stem = "jacketFrontBody"; break;
+ }
+ }
+ else
+ {
+ switch (_view)
+ {
+ case "back": stem = "jacketBack"; break;
+ case "right": stem = "jacketRight"; break;
+ case "left": stem = "jacketLeft"; break;
+ default: stem = "jacketFront"; break;
+ }
+ }
+
+ foreach (string ext in new[] { ".png", ".jpg", ".bmp" })
+ {
+ string p = IOPath.Combine("map", stem + ext);
+ if (File.Exists(p)) return Safe(p);
+ }
+
+ Emit("body not found: map/" + stem + ".*");
+ ListMapFolder();
+ return null;
+ }
+
+ private void ListMapFolder()
+ {
+ if (_listed) return;
+ _listed = true;
+
+ Emit("cwd: " + Directory.GetCurrentDirectory());
+
+ if (!Directory.Exists("map")) { Emit("map/ does not exist"); return; }
+
+ var sb = new StringBuilder();
+ sb.AppendLine("map/ contains:");
+ foreach (string f in Directory.GetFiles("map"))
+ sb.AppendLine(" " + IOPath.GetFileName(f));
+ Emit(sb.ToString());
+ }
+
+ private Gdk.Pixbuf LoadMap()
+ {
+ if (_caseCombo == null || _caseCombo.Active < 0 || _caseCombo.Active >= _cases.Count)
+ return null;
+
+ var c = _cases[_caseCombo.Active];
+ string rel;
+ switch (_view)
+ {
+ case "back": rel = c.MapBack; break;
+ case "right": rel = c.MapRight; break;
+ case "left": rel = c.MapLeft; break;
+ default: rel = c.MapFront; break;
+ }
+ if (string.IsNullOrEmpty(rel)) rel = c.MapFront;
+ if (string.IsNullOrEmpty(rel)) return null;
+
+ string full = rel;
+ if (!File.Exists(full)) { Emit("map not found: " + rel); return null; }
+
+ var raw = Safe(full);
+ if (raw == null) return null;
+
+ var keyed = raw.AddAlpha(true, 0, 0, 0);
+ raw.Dispose();
+ return keyed;
+ }
+
+ private Gdk.Pixbuf Safe(string path)
+ {
+ try { return new Gdk.Pixbuf(path); }
+ catch (Exception ex) { Emit("load failed " + path + ": " + ex.Message); return null; }
+ }
+
+ // ── the draw ───────────────────────────────────────────────────────
+
+ private void OnDraw(object o, DrawnArgs args)
+ {
+ var cr = args.Cr;
+ int w = CalibArea.AllocatedWidth, h = CalibArea.AllocatedHeight;
+
+ cr.SetSourceRGB(1, 1, 1);
+ cr.Rectangle(0, 0, w, h);
+ cr.Fill();
+
+ Gdk.Pixbuf refPb = _body ?? _map;
+ if (refPb == null) { args.RetVal = true; return; }
+
+ double s = Math.Min((double)w / refPb.Width, (double)h / refPb.Height);
+ double ox = (w - refPb.Width * s) / 2.0;
+ double oy = (h - refPb.Height * s) / 2.0;
+
+ cr.Save();
+ cr.Translate(ox, oy);
+ cr.Scale(s, s); // body-pixel space
+
+ if (_body != null && _showBody.Active)
+ {
+ Gdk.CairoHelper.SetSourcePixbuf(cr, _body, 0, 0);
+ cr.Paint();
+ }
+
+ if (_map != null && _showMap.Active)
+ {
+ double ms = Math.Min((double)refPb.Width / _map.Width,
+ (double)refPb.Height / _map.Height) * T.MapZoom;
+ double mw = _map.Width * ms, mh = _map.Height * ms;
+ double mx = (refPb.Width - mw) / 2.0 + T.MapX;
+ double my = (refPb.Height - mh) / 2.0 + T.MapY;
+
+ cr.Save();
+ cr.Translate(mx, my);
+ cr.Scale(ms, ms);
+ Gdk.CairoHelper.SetSourcePixbuf(cr, _map, 0, 0);
+ cr.PaintWithAlpha(T.MapAlpha);
+ cr.Restore();
+
+ if (_showGuides.Active)
+ {
+ cr.NewPath();
+ cr.SetSourceRGBA(1, 0, 0, 0.6);
+ cr.LineWidth = 1.5;
+ cr.Rectangle(mx, my, mw, mh);
+ cr.Stroke();
+ }
+ }
+
+ if (_showCircles.Active) DrawCircles(cr, refPb);
+
+ if (_showGuides.Active)
+ {
+ cr.NewPath();
+ cr.SetSourceRGBA(0, 0, 1, 0.35);
+ cr.LineWidth = 1.0;
+ cr.MoveTo(refPb.Width / 2.0, 0);
+ cr.LineTo(refPb.Width / 2.0, refPb.Height);
+ cr.MoveTo(0, refPb.Height / 2.0);
+ cr.LineTo(refPb.Width, refPb.Height / 2.0);
+ cr.Stroke();
+ }
+
+ cr.Restore();
+ args.RetVal = true;
+ }
+
+ private void DrawCircles(Cairo.Context cr, Gdk.Pixbuf refPb)
+ {
+ var circles = _jacket.Get(_size, _view);
+ if (circles.Count == 0) return;
+
+ int panelW = refPb.Width, panelH = refPb.Height;
+
+ float minX = float.MaxValue, maxX = float.MinValue;
+ float minY = float.MaxValue, maxY = float.MinValue;
+ foreach (var c in circles)
+ {
+ if (c.X < minX) minX = c.X;
+ if (c.X > maxX) maxX = c.X;
+ if (c.Y < minY) minY = c.Y;
+ if (c.Y > maxY) maxY = c.Y;
+ }
+
+ double availW = panelW - T.FitMargin, availH = panelH - T.FitMargin;
+ const double circleRadius = 2.1 / 2;
+ double dataW = (maxX - minX) + circleRadius * 2;
+ double dataH = (maxY - minY) + circleRadius * 2;
+ if (dataW <= 0 || dataH <= 0) return;
+
+ double scale = Math.Min(availW / dataW, availH / dataH) * 0.9;
+
+ double offX = (panelW - (maxX - minX) * scale) / 2 - minX * scale;
+ double offY = (panelH - (maxY - minY) * scale) / 2 - minY * scale;
+
+ // cluster centroid, in already-offset space
+ double avgX = 0, avgY = 0;
+ foreach (var c in circles)
+ {
+ avgX += c.X * scale + offX - T.OffsetX;
+ avgY += c.Y * scale + offY - T.OffsetY;
+ }
+ avgX /= circles.Count;
+ avgY /= circles.Count;
+
+ cr.SelectFontFace("Sans", Cairo.FontSlant.Normal, Cairo.FontWeight.Bold);
+
+ foreach (var c in circles)
+ {
+ double x = c.X * scale + offX - T.OffsetX;
+ double y = c.Y * scale + offY - T.OffsetY;
+
+ if (Math.Abs(T.Cluster - 1.0) > 0.001)
+ {
+ x = avgX + (x - avgX) * T.Cluster;
+ y = avgY + (y - avgY) * T.Cluster;
+ }
+
+ double d = CircleDiameter * scale * T.SizeScale;
+ double r = d / 2;
+
+ cr.NewPath();
+ cr.Arc(x, y, r, 0, 2 * Math.PI);
+ cr.SetSourceRGBA(0.5, 0.5, 0.5, 1.0);
+ cr.LineWidth = Math.Max(1.0, scale);
+ cr.Stroke();
+
+ string text = (c.Number % 1 == 0)
+ ? ((int)c.Number).ToString()
+ : c.Number.ToString("F1", CultureInfo.InvariantCulture);
+
+ cr.SetFontSize(Math.Max(6.0, d / (c.Inner ? 3 : 4)));
+ var ext = cr.TextExtents(text);
+ cr.MoveTo(x - ext.Width / 2 - ext.XBearing, y + ext.Height / 2);
+ cr.SetSourceRGBA(0.2, 0.2, 0.2, 1.0);
+ cr.ShowText(text);
+ cr.NewPath();
+ }
+ }
+
+ // ── output ─────────────────────────────────────────────────────────
+
+ private string DumpConstants()
+ {
+ var inv = CultureInfo.InvariantCulture;
+ var sb = new StringBuilder();
+
+ sb.AppendLine("// generated by CalibrationWindow " + DateTime.Now.ToString("yyyy-MM-dd HH:mm"));
+
+ foreach (string size in new[] { "L", "XL" })
+ {
+ sb.AppendLine("// ---- " + size + " ----");
+ foreach (string view in new[] { "front", "back", "right", "left" })
+ {
+ string k = size + ":" + view;
+ if (!_tunes.ContainsKey(k)) continue;
+
+ var t = _tunes[k];
+ string p = (size == "XL" ? "XL_" : "") + view.ToUpper();
+
+ sb.AppendLine("private const float " + p + "_OFFSET_X = " + t.OffsetX.ToString("F1", inv) + "f;");
+ sb.AppendLine("private const float " + p + "_OFFSET_Y = " + t.OffsetY.ToString("F1", inv) + "f;");
+ sb.AppendLine("private const float " + p + "_SIZE = " + t.SizeScale.ToString("F2", inv) + "f;");
+ sb.AppendLine("private const float " + p + "_CLUSTER = " + t.Cluster.ToString("F2", inv) + "f;");
+ sb.AppendLine("private const float " + p + "_FIT_MARGIN = " + t.FitMargin.ToString("F0", inv) + "f;");
+ sb.AppendLine("private const double " + p + "_MAP_ZOOM = " + t.MapZoom.ToString("F3", inv) + ";");
+ sb.AppendLine("private const double " + p + "_MAP_X = " + t.MapX.ToString("F1", inv) + ";");
+ sb.AppendLine("private const double " + p + "_MAP_Y = " + t.MapY.ToString("F1", inv) + ";");
+ sb.AppendLine("private const double " + p + "_MAP_ALPHA = " + t.MapAlpha.ToString("F2", inv) + ";");
+ sb.AppendLine();
+ }
+ }
+
+ return sb.ToString();
+ }
+
+ private void SaveCalib()
+ {
+ var inv = CultureInfo.InvariantCulture;
+ var sb = new StringBuilder();
+
+ sb.AppendLine("# size:view offsetX offsetY sizeScale cluster mapZoom mapX mapY mapAlpha fitMargin");
+ foreach (var kv in _tunes)
+ {
+ var t = kv.Value;
+ sb.AppendLine(string.Join("\t", new[]
+ {
+ kv.Key,
+ t.OffsetX.ToString(inv), t.OffsetY.ToString(inv),
+ t.SizeScale.ToString(inv), t.Cluster.ToString(inv),
+ t.MapZoom.ToString(inv), t.MapX.ToString(inv),
+ t.MapY.ToString(inv), t.MapAlpha.ToString(inv),
+ t.FitMargin.ToString(inv)
+ }));
+ }
+
+ try
+ {
+ File.WriteAllText(CalibFile, sb.ToString());
+ Emit("saved " + CalibFile);
+ }
+ catch (Exception ex) { Emit("save failed: " + ex.Message); }
+ }
+
+ private void LoadCalib()
+ {
+ string p = CalibFile;
+ if (!File.Exists(p)) return;
+
+ var inv = CultureInfo.InvariantCulture;
+ foreach (string line in File.ReadAllLines(p))
+ {
+ if (line.StartsWith("#") || line.Trim().Length == 0) continue;
+ string[] f = line.Split('\t');
+ if (f.Length < 10) continue;
+
+ var t = new Tune();
+ try
+ {
+ t.OffsetX = double.Parse(f[1], inv);
+ t.OffsetY = double.Parse(f[2], inv);
+ t.SizeScale = double.Parse(f[3], inv);
+ t.Cluster = double.Parse(f[4], inv);
+ t.MapZoom = double.Parse(f[5], inv);
+ t.MapX = double.Parse(f[6], inv);
+ t.MapY = double.Parse(f[7], inv);
+ t.MapAlpha = double.Parse(f[8], inv);
+ t.FitMargin = double.Parse(f[9], inv);
+ _tunes[f[0]] = t;
+ }
+ catch { }
+ }
+
+ Emit("loaded " + CalibFile);
+ }
+
+ private void Emit(string msg)
+ {
+ OutputView.Buffer.Text = msg + "\n\n" + OutputView.Buffer.Text;
+ }
+}
\ No newline at end of file
diff --git a/programdeltafixer/CaseDefinition.cs b/programdeltafixer/CaseDefinition.cs
new file mode 100644
index 0000000..39b9c85
--- /dev/null
+++ b/programdeltafixer/CaseDefinition.cs
@@ -0,0 +1,143 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Text;
+
+/// One row of cases.csv.
+public class CaseDefinition
+{
+ public int Number { get; set; } // Number
+ public string Type { get; set; } // Type 心音 / 呼吸音
+ public string CategoryJp { get; set; } // Category
+ public string SubcategoryJp { get; set; } // Subcategory
+ public string LocationJp { get; set; } // Location
+ public string TreeLevel1 { get; set; } // Tree_Level1
+ public string TreeLevel2 { get; set; } // Tree_Level2
+ public string TreeLevel3 { get; set; } // Tree_Level3
+ public string MapFront { get; set; } // Image_File
+ public string SoundPath { get; set; } // Sound_File
+ public string MapRight { get; set; } // Image_Right
+ public string MapLeft { get; set; } // Image_Left
+ public string MapBack { get; set; } // Image_Back
+
+ public bool IsHeart
+ {
+ get { return !string.IsNullOrEmpty(Type) && Type.Contains("心"); }
+ }
+
+ /// Non-empty levels only, e.g. 呼吸音 → 正常呼吸音 → 気管音.
+ /// Depth varies per row, which is what drives the drill-down.
+ public string[] TreePath
+ {
+ get
+ {
+ var parts = new List();
+ foreach (string s in new[] { Type, TreeLevel1, TreeLevel2, TreeLevel3 })
+ if (!string.IsNullOrWhiteSpace(s)) parts.Add(s.Trim());
+ return parts.ToArray();
+ }
+ }
+
+ public override string ToString()
+ {
+ return Number + ": " + string.Join(" : ", TreePath);
+ }
+
+ // ── loader ─────────────────────────────────────────────────────────
+
+ public static List LoadCasesFromCsv(string path)
+ {
+ var list = new List();
+
+ if (!File.Exists(path))
+ {
+ Console.WriteLine("CSV not found: " + path);
+ return list;
+ }
+
+ // Encoding.UTF8 with BOM detection. Reading Japanese as the default
+ // ANSI codepage gives mojibake that only shows up on the buttons.
+ string[] lines;
+ try
+ {
+ lines = File.ReadAllLines(path, Encoding.UTF8);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine("Failed to read " + path + ": " + ex.Message);
+ return list;
+ }
+
+ if (lines.Length < 2)
+ {
+ Console.WriteLine("CSV has no data rows: " + path);
+ return list;
+ }
+
+ // Your file is TAB separated; fall back to comma if it gets re-exported.
+ char sep = lines[0].Contains("\t") ? '\t' : ',';
+
+ // Header name -> column index, so adding or reordering columns is safe.
+ // string[] header = lines[0].TrimStart('\uFEFF').Split(sep);
+ string[] header = lines[0].TrimStart(new[] { '\uFEFF' }).Split(sep);
+ var idx = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ for (int i = 0; i < header.Length; i++)
+ {
+ string key = header[i].Trim();
+ if (key.Length > 0 && !idx.ContainsKey(key)) idx[key] = i;
+ }
+
+ if (!idx.ContainsKey("Number"))
+ {
+ Console.WriteLine("CSV header has no 'Number' column — wrong file or wrong delimiter?");
+ return list;
+ }
+
+ Func get = (fields, name) =>
+ {
+ int i;
+ if (!idx.TryGetValue(name, out i) || i >= fields.Length) return "";
+ return fields[i].Trim();
+ };
+
+ int skipped = 0;
+
+ for (int r = 1; r < lines.Length; r++)
+ {
+ if (string.IsNullOrWhiteSpace(lines[r])) continue;
+
+ string[] f = lines[r].Split(sep);
+
+ int number;
+ if (!int.TryParse(get(f, "Number"), NumberStyles.Integer,
+ CultureInfo.InvariantCulture, out number))
+ {
+ skipped++;
+ Console.WriteLine("Line " + (r + 1) + ": bad Number, skipped");
+ continue;
+ }
+
+ list.Add(new CaseDefinition
+ {
+ Number = number,
+ Type = get(f, "Type"),
+ CategoryJp = get(f, "Category"),
+ SubcategoryJp = get(f, "Subcategory"),
+ LocationJp = get(f, "Location"),
+ TreeLevel1 = get(f, "Tree_Level1"),
+ TreeLevel2 = get(f, "Tree_Level2"),
+ TreeLevel3 = get(f, "Tree_Level3"),
+ MapFront = get(f, "Image_File"),
+ SoundPath = get(f, "Sound_File"),
+ MapRight = get(f, "Image_Right"),
+ MapLeft = get(f, "Image_Left"),
+ MapBack = get(f, "Image_Back")
+ });
+ }
+
+ Console.WriteLine("Loaded " + list.Count + " cases from " + path +
+ (skipped > 0 ? " (" + skipped + " skipped)" : ""));
+ return list;
+ }
+}
\ No newline at end of file
diff --git a/programdeltafixer/JacketData.cs b/programdeltafixer/JacketData.cs
new file mode 100644
index 0000000..d09ae52
--- /dev/null
+++ b/programdeltafixer/JacketData.cs
@@ -0,0 +1,217 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+
+/// One chip position. Mirrors Form2.Circle.
+public class Circle
+{
+ public float Number { get; set; }
+ public int X { get; set; } // screen-space: OriginalX * 50 + MarginX
+ public int Y { get; set; }
+ public float OriginalX { get; set; }
+ public float OriginalY { get; set; }
+ public float OriginalZ { get; set; }
+ public bool Inner { get; set; } // non-integer Number
+}
+
+/// Loads the 8 CSVs from JacketData/ — front|back|right|left x L|XL.
+public class JacketData
+{
+ public static string AppFile(string relative) { return relative; }
+ public const int CoordScale = 50; // Form2: originalX * 50
+ public const int MarginX = 30;
+ public const int MarginY = 30;
+
+ private const string ConfigFile = "config.txt";
+ private const string DefaultDir = "JacketData";
+
+ private static readonly string[] Views = { "front", "back", "right", "left" };
+ private static readonly string[] Sizes = { "L", "XL" };
+
+ private readonly Dictionary> _l = new Dictionary>();
+ private readonly Dictionary> _xl = new Dictionary>();
+
+ public string Folder { get; private set; }
+ public bool Loaded { get; private set; }
+
+ /// Form2.GetCurrentCSVData()
+ public List Get(string size, string view)
+ {
+ var dict = (size == "XL") ? _xl : _l;
+ List list;
+ return dict.TryGetValue(view, out list) ? list : new List();
+ }
+
+ public bool Has(string size, string view)
+ {
+ var dict = (size == "XL") ? _xl : _l;
+ return dict.ContainsKey(view) && dict[view].Count > 0;
+ }
+
+ // ── folder resolution: config.txt, else JacketData/ next to the exe ──
+
+ public static string ResolveFolder()
+ {
+ string cfg = ConfigFile;
+
+ if (File.Exists(cfg))
+ {
+ try
+ {
+ string saved = File.ReadAllText(cfg).Trim();
+ if (Directory.Exists(saved) && Validate(saved))
+ {
+ Logger.Write("jacket", "folder from config.txt: " + saved);
+ return saved;
+ }
+
+ Logger.Write("jacket", "config.txt path invalid, ignoring: " + saved);
+ }
+ catch (Exception ex)
+ {
+ Logger.Write("jacket", "config.txt unreadable: " + ex.Message);
+ }
+ }
+
+ // return SoundWindow.AppFile(DefaultDir);
+ return DefaultDir;
+ }
+
+ public static void SaveFolder(string folder)
+ {
+ try { File.WriteAllText(SoundWindow.AppFile(ConfigFile), folder); }
+ catch (Exception ex) { Logger.Write("jacket", "could not save config.txt: " + ex.Message); }
+ }
+
+ /// Form1.ValidateCSVFiles — all 8 must be present.
+ public static bool Validate(string folder)
+ {
+ foreach (string v in Views)
+ foreach (string s in Sizes)
+ if (!File.Exists(Path.Combine(folder, v + "_" + s + ".csv")))
+ return false;
+ return true;
+ }
+
+ public static string[] MissingFiles(string folder)
+ {
+ var missing = new List();
+ foreach (string v in Views)
+ foreach (string s in Sizes)
+ {
+ string name = v + "_" + s + ".csv";
+ if (!File.Exists(Path.Combine(folder, name))) missing.Add(name);
+ }
+ return missing.ToArray();
+ }
+
+ // ── loading ─────────────────────────────────────────────────────────
+
+ public static JacketData Load()
+ {
+ return Load(ResolveFolder());
+ }
+
+ public static JacketData Load(string folder)
+ {
+ var data = new JacketData();
+ data.Folder = folder;
+
+ if (!Directory.Exists(folder))
+ {
+ Logger.Write("jacket", "folder not found: " + folder);
+ return data;
+ }
+
+ string[] missing = MissingFiles(folder);
+ if (missing.Length > 0)
+ Logger.Write("jacket", "missing " + missing.Length + " file(s): " + string.Join(", ", missing));
+
+ foreach (string view in Views)
+ {
+ foreach (string size in Sizes)
+ {
+ string path = Path.Combine(folder, view + "_" + size + ".csv");
+ var circles = ReadCsv(path);
+ if (circles.Count == 0) continue;
+
+ if (size == "XL") data._xl[view] = circles;
+ else data._l[view] = circles;
+
+ Logger.Write("jacket", "loaded " + circles.Count + " chips from " + view + "_" + size + ".csv");
+ }
+ }
+
+ data.Loaded = (data._l.Count > 0 || data._xl.Count > 0);
+ if (!data.Loaded) Logger.Write("jacket", "NO chip data loaded from " + folder);
+
+ return data;
+ }
+
+ /// Columns: Number, OriginalX, OriginalY, OriginalZ. Header optional.
+ private static List ReadCsv(string path)
+ {
+ var circles = new List();
+
+ if (!File.Exists(path))
+ {
+ Logger.Write("jacket", "file not found: " + path);
+ return circles;
+ }
+
+ string[] lines;
+ try { lines = File.ReadAllLines(path); }
+ catch (Exception ex)
+ {
+ Logger.Write("jacket", "read failed " + path + ": " + ex.Message);
+ return circles;
+ }
+
+ int start = 0;
+ if (lines.Length > 0)
+ {
+ string h = lines[0].ToLower();
+ if (h.Contains("number") || h.Contains("originalx")) start = 1;
+ }
+
+ for (int i = start; i < lines.Length; i++)
+ {
+ string line = lines[i].Trim();
+ if (line.Length == 0) continue;
+
+ string[] p = line.Split(',');
+ if (p.Length < 4)
+ {
+ Logger.Write("jacket", path + " line " + (i + 1) + ": only " + p.Length + " columns");
+ continue;
+ }
+
+ float number, ox, oy, oz;
+ var inv = CultureInfo.InvariantCulture;
+
+ // InvariantCulture matters — under ja_JP/de_DE, "1.5" can fail to parse.
+ if (!float.TryParse(p[0].Trim(), NumberStyles.Float, inv, out number) ||
+ !float.TryParse(p[1].Trim(), NumberStyles.Float, inv, out ox) ||
+ !float.TryParse(p[2].Trim(), NumberStyles.Float, inv, out oy) ||
+ !float.TryParse(p[3].Trim(), NumberStyles.Float, inv, out oz))
+ {
+ Logger.Write("jacket", path + " line " + (i + 1) + ": bad number format");
+ continue;
+ }
+
+ circles.Add(new Circle
+ {
+ Number = number,
+ X = (int)(ox * CoordScale) + MarginX,
+ Y = (int)(oy * CoordScale) + MarginY,
+ OriginalX = ox,
+ OriginalY = oy,
+ OriginalZ = oz,
+ Inner = (number % 1 != 0)
+ });
+ }
+
+ return circles;
+ }
+}
\ No newline at end of file
diff --git a/programdeltafixer/Logger.cs b/programdeltafixer/Logger.cs
new file mode 100644
index 0000000..85b0efe
--- /dev/null
+++ b/programdeltafixer/Logger.cs
@@ -0,0 +1,69 @@
+using System;
+using System.IO;
+using System.Text;
+
+/// Append-only text log next to the .exe: logs/ears-YYYYMMDD.log
+public static class Logger
+{
+ private static readonly object _gate = new object();
+ private static string _path;
+ private static bool _failed;
+
+ public static void Write(string message)
+ {
+ Write(null, message);
+ }
+
+ public static void Write(string tag, string message)
+ {
+ if (_failed) return;
+
+ string line = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff") +
+ (string.IsNullOrEmpty(tag) ? " " : " [" + tag + "] ") +
+ message;
+
+ lock (_gate)
+ {
+ try
+ {
+ if (_path == null) _path = Init();
+ File.AppendAllText(_path, line + Environment.NewLine, Encoding.UTF8);
+ }
+ catch (Exception ex)
+ {
+ _failed = true; // never let logging crash the app
+ Console.WriteLine("[Logger] disabled: " + ex.Message);
+ }
+ }
+ }
+
+ public static void Write(string tag, string format, params object[] args)
+ {
+ Write(tag, string.Format(format, args));
+ }
+
+ public static void Exception(string tag, Exception ex)
+ {
+ Write(tag, ex.GetType().Name + ": " + ex.Message);
+ Write(tag, ex.StackTrace ?? "(no stack)");
+ }
+
+ private static string Init()
+ {
+ string dir = Path.Combine(
+ Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location),
+ "logs");
+
+ Directory.CreateDirectory(dir);
+
+ string path = Path.Combine(dir, "ears-" + DateTime.Now.ToString("yyyyMMdd") + ".log");
+
+ File.AppendAllText(path,
+ Environment.NewLine +
+ "=== session start " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") +
+ " (" + Environment.OSVersion.Platform + ") ===" + Environment.NewLine,
+ Encoding.UTF8);
+
+ return path;
+ }
+}
\ No newline at end of file
diff --git a/programdeltafixer/SoundLooper.cs b/programdeltafixer/SoundLooper.cs
new file mode 100644
index 0000000..311d4b6
--- /dev/null
+++ b/programdeltafixer/SoundLooper.cs
@@ -0,0 +1,165 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Media;
+using System.Threading;
+
+public class SoundLooper : IDisposable
+{
+ // ── unix backend ──
+ private Process _proc;
+ private Thread _thread;
+ private volatile bool _running;
+ private string _path;
+
+ // ── windows backend ──
+ private SoundPlayer _player;
+
+ public event Action Log;
+
+ public bool IsPlaying { get { return _running; } }
+
+ private static bool IsWindows
+ {
+ get
+ {
+ var p = Environment.OSVersion.Platform;
+ return p != PlatformID.Unix && p != PlatformID.MacOSX;
+ }
+ }
+
+ public void Start(string wavPath)
+ {
+ Stop();
+
+ if (string.IsNullOrEmpty(wavPath) || !File.Exists(wavPath))
+ {
+ Emit("Sound file not found: " + wavPath);
+ return;
+ }
+
+ _path = wavPath;
+
+ if (IsWindows) StartWindows();
+ else StartUnix();
+ }
+
+ public void Stop()
+ {
+ if (!_running) return;
+ _running = false;
+
+ if (_player != null)
+ {
+ try { _player.Stop(); } catch { }
+ try { _player.Dispose(); } catch { }
+ _player = null;
+ }
+
+ try
+ {
+ var p = _proc;
+ if (p != null && !p.HasExited) p.Kill();
+ }
+ catch { }
+ _proc = null;
+
+ if (_thread != null && _thread.IsAlive) _thread.Join(500);
+ _thread = null;
+
+ Emit("Looping stopped");
+ }
+
+ // ── windows: SoundPlayer loops natively, no thread needed ──────────
+
+ private void StartWindows()
+ {
+ try
+ {
+ _player = new SoundPlayer(_path);
+ _player.Load(); // throws here if the WAV isn't plain PCM
+ _player.PlayLooping(); // gapless, runs until Stop()
+ _running = true;
+ Emit("Looping started (SoundPlayer): " + _path);
+ }
+ catch (Exception ex)
+ {
+ Emit("SoundPlayer failed for " + _path + ": " + ex.Message);
+ _player = null;
+ _running = false;
+ }
+ }
+
+ // ── unix: respawn a CLI player each pass ───────────────────────────
+
+ private void StartUnix()
+ {
+ _running = true;
+ _thread = new Thread(LoopWorker);
+ _thread.IsBackground = true;
+ _thread.Start();
+ Emit("Looping started (" + PlayerCommand() + "): " + _path);
+ }
+
+ private void LoopWorker()
+ {
+ while (_running)
+ {
+ try
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = PlayerCommand(),
+ Arguments = "\"" + _path + "\"",
+ UseShellExecute = false,
+ RedirectStandardError = true,
+ RedirectStandardOutput = true,
+ CreateNoWindow = true
+ };
+
+ _proc = Process.Start(psi);
+ _proc.WaitForExit();
+ }
+ catch (Exception ex)
+ {
+ Emit("Playback error: " + ex.Message);
+ _running = false;
+ return;
+ }
+ }
+ }
+
+ private static string _player_cmd;
+ private static string PlayerCommand()
+ {
+ if (_player_cmd != null) return _player_cmd;
+ _player_cmd = Exists("paplay") ? "paplay" : "aplay";
+ return _player_cmd;
+ }
+
+ private static bool Exists(string cmd)
+ {
+ try
+ {
+ var p = Process.Start(new ProcessStartInfo
+ {
+ FileName = "which",
+ Arguments = cmd,
+ UseShellExecute = false,
+ RedirectStandardOutput = true
+ });
+ p.WaitForExit();
+ return p.ExitCode == 0;
+ }
+ catch { return false; }
+ }
+
+ private void Emit(string msg)
+ {
+ var h = Log;
+ if (h != null) h(msg);
+ else Console.WriteLine("[SoundLooper] " + msg);
+ }
+
+ public void Dispose() { Stop(); }
+}
\ No newline at end of file
diff --git a/programdeltafixer/SoundWindow.cs b/programdeltafixer/SoundWindow.cs
new file mode 100644
index 0000000..e8b4488
--- /dev/null
+++ b/programdeltafixer/SoundWindow.cs
@@ -0,0 +1,162 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Gtk;
+using UI = Gtk.Builder.ObjectAttribute;
+
+/// Controller for notebook page 2 (SoundBox). Not a Gtk.Window — same
+/// arrangement as SettingsWindow: it drives widgets inside the shared root.
+public class SoundWindow
+{
+ [UI] private Label ConditionNameLabel = null;
+ [UI] private Grid SoundButtonGrid = null;
+ [UI] private Button SoundBackButton = null;
+
+ private const int Columns = 2;
+
+ private readonly List _allCases;
+ private readonly List _path = new List();
+
+ /// Raised when the drill-down reaches a leaf case.
+ public event EventHandler CaseSelected;
+
+ /// Raised when Back is pressed at the top level.
+ public event EventHandler BackRequested;
+
+ public SoundWindow(Builder builder)
+ {
+ builder.Autoconnect(this);
+ if (ConditionNameLabel == null || SoundButtonGrid == null || SoundBackButton == null)
+ throw new InvalidOperationException(
+ "Glade id mismatch — ConditionNameLabel=" + (ConditionNameLabel != null) +
+ " SoundButtonGrid=" + (SoundButtonGrid != null) +
+ " SoundBackButton=" + (SoundBackButton != null));
+
+ _allCases = CaseDefinition.LoadCasesFromCsv(AppFile("cases.csv"));
+ if (_allCases.Count == 0)
+ Console.WriteLine("WARNING: no cases loaded — check cases.csv");
+
+ SoundBackButton.Clicked += OnBackClicked;
+ }
+
+ /// Call every time the page becomes visible.
+ public void Reset()
+ {
+ _path.Clear();
+ Render();
+ }
+
+ /// Resolve next to the .exe, NOT the working directory.
+ public static string AppFile(string relative)
+ {
+ string dir = Path.GetDirectoryName(
+ System.Reflection.Assembly.GetExecutingAssembly().Location);
+ return Path.Combine(dir, relative);
+ }
+
+ // ── one method covers all three levels ─────────────────────────────
+
+ private void Render()
+ {
+ var matches = _allCases.Where(MatchesPath).ToList();
+
+ // Distinct values one level deeper than where we currently are.
+ var options = matches
+ .Where(c => c.TreePath.Length > _path.Count)
+ .Select(c => c.TreePath[_path.Count])
+ .Distinct()
+ .ToList();
+
+ ConditionNameLabel.Text = _path.Count == 0
+ ? "種別を選択"
+ : string.Join(" : ", _path);
+
+ BuildButtons(options, picked =>
+ {
+ _path.Add(picked);
+
+ // Landed on a leaf? Play it and stay put.
+ var leaf = _allCases.FirstOrDefault(
+ c => c.TreePath.Length == _path.Count && MatchesPath(c));
+
+ if (leaf != null)
+ {
+ Play(leaf);
+ _path.RemoveAt(_path.Count - 1);
+ return;
+ }
+
+ Render();
+ });
+ }
+
+ private bool MatchesPath(CaseDefinition c)
+ {
+ string[] p = c.TreePath;
+ if (p.Length < _path.Count) return false;
+ for (int i = 0; i < _path.Count; i++)
+ if (!string.Equals(p[i], _path[i], StringComparison.Ordinal)) return false;
+ return true;
+ }
+
+ private void BuildButtons(IEnumerable labels, Action onPick)
+ {
+ foreach (var child in SoundButtonGrid.Children)
+ {
+ SoundButtonGrid.Remove(child);
+ child.Destroy();
+ }
+
+ int i = 0;
+ foreach (string text in labels)
+ {
+ string captured = text; // don't close over the loop variable
+ var btn = new Button(captured);
+ btn.Hexpand = true;
+ btn.Clicked += (s, e) => onPick(captured);
+ SoundButtonGrid.Attach(btn, i % Columns, i / Columns, 1, 1);
+ i++;
+ }
+
+ SoundButtonGrid.ShowAll(); // widgets made in code start hidden
+ }
+
+ private void Play(CaseDefinition c)
+ {
+ if (string.IsNullOrWhiteSpace(c.SoundPath))
+ {
+ Console.WriteLine("No Sound_File for case " + c.Number);
+ return;
+ }
+
+ WavePlayer.Stop(); // the map page owns audio from here on
+
+ var h = CaseSelected;
+ if (h != null) h(this, c);
+
+ // string full = AppFile(c.SoundPath); // "sound/SND200.wav" -> absolute
+ // if (!File.Exists(full))
+ // {
+ // Console.WriteLine("Sound file missing: " + full);
+ // return;
+ // }
+
+ // ConditionNameLabel.Text = string.Join(" : ", _path);
+ // WavePlayer.Play(full);
+ }
+
+ private void OnBackClicked(object sender, EventArgs e)
+ {
+ if (_path.Count > 0)
+ {
+ _path.RemoveAt(_path.Count - 1);
+ Render();
+ }
+ else
+ {
+ WavePlayer.Stop();
+ if (BackRequested != null) BackRequested(this, EventArgs.Empty);
+ }
+ }
+}
\ No newline at end of file
diff --git a/programdeltafixer/WavePlayer.cs b/programdeltafixer/WavePlayer.cs
new file mode 100644
index 0000000..00cd07a
--- /dev/null
+++ b/programdeltafixer/WavePlayer.cs
@@ -0,0 +1,60 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+
+public static class WavePlayer
+{
+ private static Process _current;
+
+ private static bool IsUnix
+ {
+ get
+ {
+ int p = (int)Environment.OSVersion.Platform;
+ return p == 4 || p == 6 || p == 128;
+ }
+ }
+
+ public static void Play(string absolutePath)
+ {
+ Stop();
+
+ if (!IsUnix)
+ {
+ try
+ {
+ var sp = new System.Media.SoundPlayer(absolutePath);
+ sp.Play();
+ }
+ catch (Exception ex) { Console.WriteLine("Playback failed: " + ex.Message); }
+ return;
+ }
+
+ foreach (string player in new[] { "paplay", "aplay" })
+ {
+ try
+ {
+ var psi = new ProcessStartInfo(player, "\"" + absolutePath + "\"")
+ {
+ UseShellExecute = false,
+ RedirectStandardError = true
+ };
+ _current = Process.Start(psi);
+ return;
+ }
+ catch { /* not installed, try the next one */ }
+ }
+
+ Console.WriteLine("No audio player found — install pulseaudio-utils or alsa-utils");
+ }
+
+ public static void Stop()
+ {
+ try
+ {
+ if (_current != null && !_current.HasExited) _current.Kill();
+ }
+ catch { }
+ _current = null;
+ }
+}
\ No newline at end of file
diff --git a/programdeltafixer/calibrate.glade b/programdeltafixer/calibrate.glade
new file mode 100644
index 0000000..66eccc6
--- /dev/null
+++ b/programdeltafixer/calibrate.glade
@@ -0,0 +1,144 @@
+
+
+
+
+ False
+ EARS — Calibration
+ 1280
+ 800
+
+
+ True
+ horizontal
+ 10
+
+
+ True
+ True
+ 380
+ never
+
+
+ True
+
+
+ True
+ vertical
+ 6
+ 10
+ 10
+ 10
+ 10
+
+
+
+
+
+
+ False
+ True
+ 0
+
+
+
+
+ True
+ vertical
+ 6
+ True
+ 10
+ 10
+ 10
+
+
+ True
+ True
+ True
+
+
+ True
+ True
+ 0
+
+
+
+
+ True
+ 6
+
+
+ Dump C# constants
+ True
+ True
+ True
+
+
+ True
+ True
+ 0
+
+
+
+
+ Save calib.txt
+ True
+ True
+ True
+
+
+ True
+ True
+ 1
+
+
+
+
+ Reset view
+ True
+ True
+ True
+
+
+ False
+ True
+ 2
+
+
+
+
+ False
+ True
+ 1
+
+
+
+
+ True
+ True
+ 170
+ in
+
+
+ True
+ False
+ True
+
+
+
+
+ False
+ True
+ 2
+
+
+
+
+ True
+ True
+ 1
+
+
+
+
+
+
\ No newline at end of file
diff --git a/programdeltafixer/program.cs b/programdeltafixer/program.cs
new file mode 100644
index 0000000..46b4c1a
--- /dev/null
+++ b/programdeltafixer/program.cs
@@ -0,0 +1,23 @@
+using System;
+using System.IO;
+using System.Reflection;
+using Gtk;
+
+class Program
+{
+ [STAThread]
+ static void Main(string[] args)
+ {
+ // Resolve every relative path (calibrate.glade, map/, cases.csv,
+ // JacketData/) against the exe's folder, not the launch directory.
+ Directory.SetCurrentDirectory(
+ Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location));
+
+ Application.Init();
+
+ var app = new CalibrationWindow();
+ app.ShowAll();
+
+ Application.Run();
+ }
+}
\ No newline at end of file