# HamsterOS App Developer Guide

**Revision:** v1.1 (July 2026) — HAMA App Format v1

---

## Overview

HamsterOS apps are standalone 32-bit i386 binaries loaded from the floppy at
runtime. Shipped apps live in four standard folders:

| Directory | Purpose |
|---|---|
| `/APPS/` | System apps converted from built-in code (e.g. `NOTEPAD.APP`) |
| `/TOOLS/` | Hardware tool apps — Disk Copier, CD Player, Floppy Diag, CD ROM Diag, IDE Diag, Format, GFX Diag |
| `/USER/` | User-installed third-party apps and bundled user apps |

The Programs menu and file manager expose application directories. Double-clicking
a `.APP` in those folders reads the file from disk,
allocates heap memory, copies the code into it, applies relocations, and calls
your `app_entry()` function. When the app closes the heap is freed automatically.

There is no kernel reboot, no relink, no persistent process list. Apps are
purely event-driven: the OS calls your draw/input/update functions on demand.

Because floppy and FAT reads are synchronous in the current shell, file-manager
launch paths and system-app loaders paint a small immediate "Loading <name>"
toast before starting blocking disk I/O. This keeps the UI from feeling dead
during external app loads. While the kernel is in the protected `.APP` read
path, it presents the busy cursor/loading toast atomically from the service-time
mouse path so users do not see cursor trails or stale half-frames. The pointer
position is still tracked and normal drawing resumes when the app opens or an
error dialog appears.

For apps that need large temporary scratch buffers only during one operation
(for example BMP load/save staging or a flood-fill queue), HamsterOS also
exposes a shared transient workspace API. This lets short-lived work areas
come from one reusable pool instead of forcing every app to keep a dedicated
persistent heap reservation.

### Raw SDK vs planned simple UI layer

This document currently describes the raw HAMA app interface.  The planned HUI
layer should sit above it for ordinary apps:

| Layer | Developer writes | Best for |
| --- | --- | --- |
| Raw HAMA | Descriptor, stubs, callbacks, window state, cleanup. | Browsers, editors, games, diagnostics, custom rendering. |
| Planned HUI | Window/control declarations; OS generates callbacks and cleanup. | Simple tools, dialogs, settings panels, about boxes. |

Target HUI-style hello-world shape:

```c
HUI_APP("HELLO WORLD")
HUI_WINDOW(200, 200, "HELLO WORLD")
HUI_TEXT(12, 24, "HELLO WORLD")
```

| HUI should automatically provide | Examples |
| --- | --- |
| Descriptor callbacks | `init`, `open`, `close`, input, draw, bounds. |
| Standard UI behavior | `WindowFrame`, menus, focus, pointer capture. |
| Common controls | Text, buttons, fields, images, message boxes. |
| Cleanup | App-scoped images, timers, audio, workspace, buffers. |

Full requirements are tracked in `docs/UI_UX_AUDIT.md` under "Developer platform
requirements."

### Standard app contract — roadmap Step 2.1

New apps should be designed as if HamsterOS might ship from ROM, floppy, or a
very small hard-disk install.  The right default is to reuse system services
instead of embedding private menus, dialogs, strings, icons, decoders, caches,
or scratch buffers.

Every shipped app should have one authoritative manifest/descriptor contract.
The current `AppDescriptor` is the runtime callback part of that contract; the
next standardization pass should extend the surrounding metadata so File
Manager, Update.app, Help, Control Panel, localization, and installers all agree
about the app.

| Manifest item | Required expectation |
| --- | --- |
| App ID | Stable machine ID, independent of localized display name. |
| Display name | String ID, not a hardcoded English literal. |
| Version | Embedded scan-friendly component tag such as `HAMAPP_VERSION=.NN.NN`. |
| API compatibility | Minimum OS/API version for a clear “needs newer HamsterOS” error. |
| Menus | Shell-owned menu model with string IDs, accelerators, shortcuts, command IDs, disabled states, and Help/About/Quit where applicable. |
| Help | `help_path` or `help_topic` for F1; shell falls back to `HELP\HELP.HTM` if missing. |
| File types | Extensions, localized type names, icons, and open/save/import/export verbs. |
| Resources | Icons, strings, sounds, sprites, templates, and language/resource-pack IDs. |
| Memory budget | Static image, BSS, heap, workspace scratch, cache policy, and low-memory behavior. |
| Hardware needs | Optional devices such as SB16, NE2000, CD-ROM, VBE, mouse wheel, or joystick. |
| Update identity | Component/package name used by Update.app manifests. |
| Cleanup | Files, heap, workspace, cache, audio voice, timers, network sockets, drag state, and any app-local buffers released on close. |

### String IDs and localization

Do not add new user-visible UI strings as permanent source literals.  The
target standard is a tiny string-catalog service:

```c
const char *ham_str(HamStringId id);
```

Rules for app authors:

- English is the built-in fallback.
- Spanish is the first alternate language catalog.
- Control Panel owns the selected language.
- Missing catalogs or missing translations must fall back per string, not crash.
- Accelerator/access-key choices belong with the localized strings because
  Spanish labels may need different underlined letters.
- File paths, manifest keys, file formats, protocol headers, driver IDs, and
  raw diagnostic codes stay ASCII/stable in v1.
- Apps should size or clip controls for longer translated text instead of
  assuming English label widths.

### ROM/floppy/RAM resource discipline

Before adding an app feature, answer these questions:

| Question | Preferred answer |
| --- | --- |
| Does a shared control already do this? | Use it; do not draw a private version. |
| Does a shared icon/sound/string/helper already exist? | Reference the shared resource ID. |
| Does the feature need a large temporary buffer? | Borrow workspace only during the operation. |
| Does the feature cache data? | Make it bounded, purgeable, and hard-disk-only unless it is core boot behavior. |
| Does the feature add a decoder/decompressor/parser? | Reuse the shared one or justify a measured exception. |
| Does the feature add strings/help/settings? | Add string IDs, help path/topic, settings defaults, and localization notes with the feature. |
| Does the feature grow an artifact? | Record app/kernel/resource size impact and expected RAM impact. |

Build/resource budget reports are generated with:

```sh
make budget-report
```

This writes `build/Reports/budget_report.md`, `.csv`, `.json`, plus
`budgetreport.xls` and `budgetreport.xlsx`. The report measures each built
`.APP`, `.DRV`, and `.DLL`, its ELF `.text/.rodata/.data/.bss` sections, sidecar
resources such as `.ICO` files, and the current scratch/cache/cleanup contract
status. The full policy lives in `docs/resource_budget_standards.md`.

---

## Quick-start: three required files

Every app needs three source files: `myapp.c` (your logic), `myapp_stubs.c`
(OS call wrappers), and `myapp_ext_entry.c` (the `app_entry()` function).
See `apps/piano_stubs.c` and `apps/piano_ext_entry.c` for complete examples.

### myapp_ext_entry.c (always the same shape)

```c
#include "app_abi.h"
#include <stddef.h>

/* Forward-declare functions from myapp.c and myapp_stubs.c. */
void myapp_set_host(HamsterHostAPI *h);
void myapp_stubs_set_host(HamsterHostAPI *h);
void myapp_init(void); void myapp_open(void); void myapp_close(void);
bool myapp_is_open(void); bool myapp_is_active(void); bool myapp_has_modal(void);
void myapp_set_active(bool);
bool myapp_scancode(uint8_t);
bool myapp_ptr_down(int16_t, int16_t);
bool myapp_ptr_move(int16_t, int16_t);
bool myapp_ptr_up(int16_t, int16_t);
void myapp_draw(void);
void myapp_bounds(int16_t*, int16_t*, int16_t*, int16_t*);

static AppDescriptor g_desc = {
    .init                = myapp_init,
    .open                = myapp_open,
    .close               = myapp_close,
    .is_open             = myapp_is_open,
    .is_active           = myapp_is_active,
    .has_modal           = myapp_has_modal,
    .set_active          = myapp_set_active,
    .handle_scancode     = myapp_scancode,
    .handle_pointer_down = myapp_ptr_down,
    .handle_pointer_move = myapp_ptr_move,
    .handle_pointer_up   = myapp_ptr_up,
    .draw                = myapp_draw,
    .get_bounds          = myapp_bounds,
};

AppDescriptor *app_entry(HamsterHostAPI *host) {
    myapp_set_host(host);
    myapp_stubs_set_host(host);
    return &g_desc;
}
```

---

## Build toolchain

Requirements: `hamsteros-builder` Docker image (same one used to build the OS).

```sh
CC_FLAGS="-m32 -march=i386 -std=c11 -ffreestanding -fno-builtin -fno-pic -fno-pie \
          -fno-stack-protector -Ikernel -Idrivers -Iapps -Ifs -Wall -Wextra -O2"

gcc $CC_FLAGS -c myapp.c           -o myapp.o
gcc $CC_FLAGS -c myapp_stubs.c     -o myapp_stubs.o
gcc $CC_FLAGS -c myapp_ext_entry.c -o myapp_ext.o

ld -T tools/app.ld -melf_i386 --emit-relocs -nostdlib \
   myapp_ext.o myapp.o myapp_stubs.o -o myapp.elf

python3 tools/mkapp.py --elf myapp.elf --name MYAPP --output MYAPP.APP
```

Copy `MYAPP.APP` to `/USER/` on the floppy or put it in the extras image layout
for B:. It appears in the matching file-manager folder.
For Makefile integration see the `PIANO_APP` and `SLOTS_APP` rules in `Makefile`.

`--emit-relocs` is required. `mkapp.py` reads every `R_386_32` entry from the
ELF `.rel.*` sections and writes those patch offsets into the HAMA relocation
table. These relocations include obvious static data pointers and less-obvious
function pointers stored in `AppDescriptor`. If descriptor relocations are
missing, the app may load successfully and then soft-crash when the OS calls
`init()`, `open()`, or another descriptor callback.

---

## HAMA v1 file format

```
Offset  Size  Field         Description
------  ----  -----         -----------
0       4     magic         0x414D4148 ("HAMA" little-endian)
4       2     version       1  (loader rejects anything else)
6       2     flags         See the HAMA v1 flags below
8       4     code_size     Unpacked bytes of code+rodata+data after optional icon
12      4     bss_size      Bytes of zeroed BSS allocated at runtime
16      4     entry_offset  Byte offset of app_entry() in code blob
20      4     reloc_count   Number of uint32 patch offsets after code
24      8     name          Display name: ASCII, null-padded
32      256   icon          Optional 16x16 VGA icon when HAS_ICON is set
32/288  4     packed_size   Present only when COMPRESSED is set
...     ...   code blob     code_size bytes when uncompressed, packed_size bytes when compressed
...     ...   reloc table   reloc_count x uint32_t patch offsets
```

HAMA v1 flags are append-only:

- `0x0001 HAS_ICON` — a 256-byte 16x16 VGA icon follows the header.
- `0x0002 COMPRESSED` — a packed-size field and raw-deflate code blob are used.
- `0x0004 ALIAS` — the payload is a NUL-terminated target path.
- `0x0008 OPEN_LAUNCH_AT`, `0x0010 APP_MENUS`, and `0x0020 RIGHT_CLICK` opt
  into the corresponding appended `AppDescriptor` callbacks.
- `0x0040 HIGH_MEMORY` raises only that app's structured file-image limit from
  512 KiB to 1 MiB and its runtime-image limit from 640 KiB to 1 MiB. It is
  intended for apps that require a 4 MB-or-larger system; normal apps retain
  the lower limits, and launch still requires enough free heap for the staging
  image, runtime image, and system reserve.

If `HAS_ICON` is set, the 256-byte icon is stored immediately after the header
and before the code blob. If `COMPRESSED` is set, a little-endian `packed_size`
field sits after the optional icon and before the packed blob. The loader must
skip both the icon block and the optional size field before copying or unpacking
code and before locating the relocation table.

**Load sequence (kernel/app_loader.c):**
1. `heap_alloc(file_size)` staging buffer, load whole file.
2. Validate magic and version.
3. `heap_alloc(code_size + bss_size)` runtime region.
4. Skip the optional icon block, then either copy the code blob directly or
   unpack it into the runtime buffer, then zero BSS.
5. Apply relocations: `*(uint32_t*)(base + offset) += (uint32_t)base`.
6. `heap_free(staging)` — staging freed immediately.
7. Call `app_entry(host)` at `base + entry_offset`.
8. The owner slot/loader calls `desc->init()` before first `desc->open()`.
9. On close: `heap_free(code_buf)` — all RAM reclaimed.

If the app is launched from a shell surface that performs blocking disk reads
on the UI thread, draw and present a loading toast before step 1. Current
file-manager and lazy-loader code uses a centered `170x34` light-gray box with a
dark-gray border, bold text like `Loading SNAKE`, an hourglass/busy cursor, and
an immediate `vga_present_framebuffer()`. During the blocking load, service-time
mouse drawing re-presents the busy frame atomically so cursor motion cannot
paint through the toast or reveal the app below. The stored pointer position is
kept and is redrawn normally after loading completes.

Load failures must be nonfatal. Missing files, malformed headers, invalid
descriptor callbacks, bad 8.3 filenames, out-of-memory conditions, and disk read
errors should call `shell_show_error()` or return an error that the owning shell
surface converts into a dialog. Never leave a loading toast on screen as the
final state.

### Compression and aliases

`tools/mkapp.py` now supports two build-time space-saving features:

```sh
python3 tools/mkapp.py --elf myapp.elf --name MYAPP --output MYAPP.APP
python3 tools/mkapp.py --elf myapp.elf --name MYAPP --no-compress --output MYAPP.APP
python3 tools/mkapp.py --alias-target /TOOLS/MYAPP.APP --name MYAPP --output MYAPP.APP
```

- Normal `.APP` builds automatically compress the code blob when that makes the
  file smaller enough to be worth it. The loader inflates the blob after reading
  it from disk, so floppy launch I/O drops without changing the in-memory app.
- `--no-compress` forces the older uncompressed layout.
- `--alias-target` creates a tiny shortcut `.APP`. Alias apps are for shell
  surfaces such as `/DESKTOP`; they point at the real app path and are not
  executable payloads themselves.

Relocation offsets are flat offsets in the runtime code/data blob because app
ELFs are linked as `ET_EXEC` at address 0. Do not add the target section base
again when packing relocations. Doing so drops relocations outside `.text`,
including descriptor callback pointers.

---

## Host services reference (HamsterHostAPI v1)

All services via the `HamsterHostAPI*` passed to `app_entry()`.
**Never call OS functions directly — only through this pointer.**

### Drawing (VGA)

| Field | Signature | Notes |
|---|---|---|
| `fill_rect` | `(x, y, w, h, color)` | Fill rectangle |
| `draw_text` | `(x, y, str, color, scale)` | Bitmap text; scale 1 or 2 |
| `draw_border` | `(x, y, w, h, color)` | 1-pixel border |

Colors: `VGA_COLOR_*` constants 0-15 from `vga.h`.

### Filesystem (FAT)

| Field | Notes |
|---|---|
| `fat_load(drive, dir, name, buf, max, &sz, &trunc)` | Load file into buffer |
| `fat_save(drive, dir, name, buf, sz)` | Save buffer to file |
| `fat_error()` | Last FAT error string |

`drive`: `FAT_DRIVE_BOOT` = A:, `FAT_DRIVE_B` = B:, `FAT_DRIVE_HOST` = C: (first IDE), `FAT_DRIVE_RAM` = R:. `dir` = 0 for root directory.

**Loading from a subdirectory** — `dir` is a FAT cluster number, not a path string. To reach a nested folder directly, resolve the cluster chain with `fat_list_dir`. Apps that need assets beside their executable should implement `open_launch_at(drive, cluster, name)`, store that drive/cluster as their asset base, then run their normal open path. Pack those apps with `mkapp.py --open-launch-at`; older apps without that HAMA flag continue to receive `open()`. `open_file_at(drive, cluster, name)` is for opening a user-selected document.

**`.APP` files on non-boot drives** — the OS shell now supports double-clicking a `.APP` on any drive (B:, C:, etc.). The loader calls `app_loader_load_drive(drive, cluster, filename)` so the binary is read from the correct device. No special handling needed in your app.

### File dialog

| Field | Notes |
|---|---|
| `fd_init(state, x, y)` | Init a `FileDialogState` |
| `fd_open(state, mode, drive, default_name, cfg, ctx)` | Open LOAD or SAVE dialog |
| `fd_close(state)` | Close without accepting |
| `fd_draw(state, cfg)` | Draw dialog |
| `fd_scancode(state, sc, shift, cfg, ctx)` | Returns ACCEPT or CANCEL |
| `fd_pointer_down(state, x, y, cfg, ctx)` | Mouse click |
| `fd_translate_key(sc, shift)` | Scancode to ASCII for filename input |
| `fd_current_cluster(state)` | Directory cluster for fat_load/fat_save |
| `fd_filename_83_valid(name)` | Legacy-named validator; accepts the current FAT filename contract (8.3, or VFAT names up to 63 characters when enabled) |

`FileDialogConfig.extension_filter` may be `NULL`, a single extension such as
`"BMP"`, or a semicolon/comma/space separated list such as
`"BMP;GIF;JPG;JPEG;PNG"`.  The shared dialog displays that filter in a `TYPE`
dropdown and always adds `All files (*.*)` so users can recover from a too-narrow
filter without each app inventing its own file picker.

For friendlier labels, provide `FileDialogFilterChoice` entries through
`FileDialogConfig.filters` and `filter_count`.  Each choice supplies a visible
label and the extension list to apply.  Directories remain visible under every
filter.

### Standard input modal

Use `UiInputModal` for small text prompts such as Run, Rename, New Folder,
Relabel Disk, Find text, and any future app prompt that asks for one line of
text.  The control owns standard window chrome, field layout, status text,
`Enter` commit, `Esc` cancel, close button handling, pointer drag, and
`UiEditBuffer` editing.

| Field / function | Notes |
|---|---|
| `UiInputModal` | Caller-owned state; no heap allocation |
| `UiInputModalConfig` | Title, prompt, button labels, max length, character filter, commit callback |
| `ui_input_modal_open(modal, cfg, initial, status)` | Opens centered with optional initial text/status |
| `ui_input_modal_draw(modal)` | Draws standard modal chrome, prompt, edit field, status, buttons |
| `ui_input_modal_handle_scancode(modal, sc, shift, ctrl)` | `Enter` commits; `Esc` cancels; other keys edit |
| `ui_input_modal_handle_pointer_down/move/up(...)` | Standard close/title-drag/field/button behavior |
| `ui_input_modal_get_bounds(modal, &x,&y,&w,&h)` | Lets shell/window-manager redraw modal damage |

Minimal pattern:

```c
static UiInputModal input;

static void commit_name(UiInputModal *modal, void *ctx)
{
    (void)ctx;
    /* use modal->text, then close when valid */
    ui_input_modal_close(modal);
}

static void open_name_prompt(void)
{
    UiInputModalConfig cfg = {
        "NEW FOLDER", "FOLDER NAME:", "CREATE", "CANCEL",
        8, true, false, my_name_char_allowed, commit_name, NULL, 8
    };
    ui_input_modal_open(&input, &cfg, NULL, "8 CHARS MAX");
}
```

### Standard message box / confirmation modal

Use `UiMessageBox` for information, warning, error, confirmation, and
destructive-confirmation dialogs.  Do not hand-draw app-private OK/Cancel
boxes.  The control owns standard chrome, icon style, wrapped message text,
default button focus, `Enter` activation, `Esc` cancellation, close button
handling, title dragging, and redraw bounds.

| Field / function | Notes |
|---|---|
| `UiMessageBox` | Caller-owned state; no heap allocation |
| `UiMessageBoxConfig` | Title, message, optional detail, kind, button set, default/cancel results, callback |
| `ui_message_box_open(box, cfg)` | Opens centered and sizes to text/buttons |
| `ui_message_box_draw(box)` | Draws standard modal chrome, kind icon, wrapped text, and buttons |
| `ui_message_box_handle_scancode(box, sc)` | `Enter` activates focused/default button; `Esc` returns cancel result |
| `ui_message_box_handle_pointer_down/move/up(...)` | Standard close/title-drag/button behavior |
| `ui_message_box_get_bounds(box, &x,&y,&w,&h)` | Lets shell/window-manager redraw modal damage |

Minimal destructive confirmation:

```c
static UiMessageBox confirm;

static void confirm_delete(UiMessageBox *box, UiMessageBoxResult result, void *ctx)
{
    (void)box;
    if (result == UI_MESSAGE_BOX_RESULT_YES) {
        delete_selected_file(ctx);
    }
}

static void open_delete_confirm(void *file_ctx)
{
    UiMessageBoxConfig cfg = {
        "DELETE FILE",
        "Delete the selected file?",
        "This cannot be undone.",
        UI_MESSAGE_BOX_DANGER,
        UI_MESSAGE_BOX_BUTTONS_YES_NO,
        UI_MESSAGE_BOX_RESULT_NO,
        UI_MESSAGE_BOX_RESULT_NO,
        confirm_delete,
        file_ctx,
        8
    };
    ui_message_box_open(&confirm, &cfg);
}
```

### Standard About box

Use `UiAboutBox` for app About windows.  Do not hand-draw app-private About
frames.  The control owns standard chrome, optional 16x16 app icon, app name,
version, author/vendor, copyright/detail lines, OK button focus, `Enter`/Space
OK activation, `Esc`/close dismissal, title dragging, callback result delivery,
and redraw bounds.

| Field / function | Notes |
|---|---|
| `UiAboutBox` | Caller-owned state; no heap allocation |
| `UiAboutBoxConfig` | Title, app name, version, author, copyright, detail, optional 16x16 icon, callback |
| `ui_about_box_open(box, cfg)` | Opens centered and sizes to the supplied lines |
| `ui_about_box_draw(box)` | Draws standard About chrome, icon/badge, text lines, and OK button |
| `ui_about_box_handle_scancode(box, sc)` | `Enter`/Space activates OK; `Esc` closes |
| `ui_about_box_handle_pointer_down/move/up(...)` | Standard close/title-drag/OK button behavior |
| `ui_about_box_get_bounds(box, &x,&y,&w,&h)` | Lets shell/window-manager redraw modal damage |

Minimal pattern:

```c
static UiAboutBox about;

static void open_about(void)
{
    UiAboutBoxConfig cfg = {
        "ABOUT NOTEPAD",
        "NOTEPAD",
        "VERSION .75.00",
        "MEAN HAMSTER SOFTWARE",
        "JOHN SWIDERSKI",
        "HAMSTEROS STANDARD APP",
        "OK",
        NULL,
        0,
        0,
        NULL,
        NULL,
        NULL,
        8
    };
    ui_about_box_open(&about, &cfg);
}
```

### Standard progress dialog and progress bar

Use `UiProgressDialog` for long operations such as update installs, downloads,
file copies, disk copy passes, formatting, partition operations, extras
installation, and cache cleanup.  Use `ui_progress_draw_bar()` when an app needs
an embedded progress bar inside an existing standard panel.

The shared progress dialog owns standard chrome, status/detail text, determinate
or indeterminate progress, disabled/enabled Cancel button state, `Esc` cancel
when cancellation is enabled, close-button handling, title dragging, callback
result delivery, and redraw bounds.

| Field / function | Notes |
|---|---|
| `UiProgressDialog` | Caller-owned state; no heap allocation |
| `UiProgressDialogConfig` | Title, status/detail text, total, indeterminate flag, cancel label/state, callback |
| `ui_progress_dialog_open(dialog, cfg)` | Opens centered with initial status and total |
| `ui_progress_dialog_set_progress(dialog, value, total)` | Sets determinate progress |
| `ui_progress_dialog_set_indeterminate(dialog, true)` | Uses a pulsing bar for unknown-length work |
| `ui_progress_dialog_pulse(dialog)` | Advances the pulsing bar once per app update/tick |
| `ui_progress_dialog_set_status/detail(...)` | Updates user-visible operation text |
| `ui_progress_dialog_set_cancel_enabled(dialog, enabled)` | Controls whether Cancel/Esc/close can abort |
| `ui_progress_dialog_draw(dialog)` | Draws chrome, text, progress, and Cancel button |
| `ui_progress_draw_bar(rect, value, total, indeterminate, pulse, error)` | Reusable embedded progress bar helper |

Minimal pattern:

```c
static UiProgressDialog progress;

static void progress_cancel(UiProgressDialog *dialog, UiProgressResult result, void *ctx)
{
    (void)dialog;
    if (result == UI_PROGRESS_RESULT_CANCEL) {
        request_cancel(ctx);
    }
}

static void open_copy_progress(void *copy_ctx)
{
    UiProgressDialogConfig cfg = {
        "COPYING FILES",
        "Copying files...",
        "Preparing...",
        "CANCEL",
        100,
        false,
        true,
        progress_cancel,
        copy_ctx,
        8
    };
    ui_progress_dialog_open(&progress, &cfg);
}
```

### Standard list/detail views

Use `UiListView` for row-based views such as update lists, history/favorites
pickers, diagnostic lists, device/property lists, file-manager detail panes,
and other table-like UI.  The control is caller-owned and no-heap: it asks the
app for visible row/column text through a callback, so large lists do not have
to be copied into toolkit memory.

The shared list view owns the standard list frame, optional column header,
selection highlight, scrollbar, wheel handling, arrow/page/home/end keys,
Enter/Space activation, and selected-row scroll clamping.

| Field / function | Notes |
|---|---|
| `UiListView` | Caller-owned state; no heap allocation |
| `UiListViewColumn` | Column title, pixel width, and right-align flag |
| `UiListViewTextCallback` | Supplies cell text on demand into a small caller-provided buffer |
| `ui_list_view_init(view, rect, columns, count, text_cb, ctx)` | Creates a list/table view |
| `ui_list_view_set_rows(view, total_rows)` | Updates row count and clamps selection/scroll |
| `ui_list_view_draw(view)` | Draws header, rows, selection, grid, and scrollbar |
| `ui_list_view_handle_scancode(view, scancode)` | Standard keyboard navigation and activation |
| `ui_list_view_handle_pointer_down(view, x, y)` | Standard row selection, row activation, scrollbar hits |
| `ui_list_view_handle_wheel(view, x, y, wheel)` | Standard wheel scrolling |
| `ui_list_view_row_at(view, x, y, &row)` | Hit-test helper for app-specific context menus |

Minimal pattern:

```c
static const UiListViewColumn columns[] = {
    { "NAME", 120, false },
    { "TYPE", 48, false },
    { "SIZE", 56, true }
};
static UiListView files;

static void file_cell(uint16_t row, uint8_t column,
    char *out, uint16_t out_size, void *ctx)
{
    (void)ctx;
    fill_file_column(row, column, out, out_size);
}

static void open_file_list(UiRect rect, uint16_t count)
{
    ui_list_view_init(&files, rect, columns, 3, file_cell, NULL);
    ui_list_view_set_rows(&files, count);
}
```

### Standard property/settings sheet

Use `UiPropertySheet` for settings/property windows with a left category list
and right-side page content.  This is the migration target for Control Panel,
HamNet Settings, file/app Properties, and any app-specific settings dialog.
The sheet owns standard chrome, page list selection, OK/Cancel/Apply buttons,
keyboard navigation, title dragging, close behavior, and redraw bounds.  The
app supplies page labels and draws the selected page inside the provided content
rectangle.

| Field / function | Notes |
|---|---|
| `UiPropertySheet` | Caller-owned state; no heap allocation |
| `UiPropertyPage` | Page label, page ID, and enabled flag |
| `UiPropertySheetConfig` | Title, pages, initial page, size, Apply behavior, callbacks |
| `ui_property_sheet_open(sheet, cfg)` | Opens centered with standard chrome |
| `ui_property_sheet_content_rect(sheet)` | Returns the page drawing area |
| `ui_property_sheet_draw(sheet)` | Draws page list, page panel, and buttons |
| `ui_property_sheet_handle_scancode/pointer_*` | Standard keyboard and mouse behavior |

### Standard wizard / installer flow

Use `UiWizard` for multi-step flows such as HD install, Extras install, first-run
setup, file copy wizards, and future guided repair tools.  The wizard owns
standard chrome, step title/subtitle, step counter, Back/Next/Finish/Cancel
buttons, keyboard navigation, title dragging, close behavior, and redraw bounds.
The app supplies pages and draws the current step inside the provided content
rectangle.

| Field / function | Notes |
|---|---|
| `UiWizard` | Caller-owned state; no heap allocation |
| `UiWizardPage` | Step title and subtitle |
| `UiWizardConfig` | Title, pages, initial step, size, navigation callbacks |
| `ui_wizard_open(wizard, cfg)` | Opens centered with standard wizard chrome |
| `ui_wizard_content_rect(wizard)` | Returns the current step drawing area |
| `ui_wizard_draw(wizard)` | Draws heading, content panel, step counter, and buttons |
| `ui_wizard_handle_scancode/pointer_*` | Standard Back/Next/Finish/Cancel behavior |

### String lookup / localization skeleton

Use `ham_string(id)` for shared UI strings instead of hardcoding common labels
such as OK, Cancel, Apply, Back, Next, Finish, About, Settings, Open, Save, and
Quit.  The first runtime skeleton lives in `kernel/ham_strings.*`.

| Field / function | Notes |
|---|---|
| `HamStringId` | Stable symbolic ID for shared UI strings |
| `HamLanguage` | Built-in language selector: English and Spanish |
| `ham_string(id)` | Returns current-language text with English fallback |
| `ham_string_for_language(lang, id)` | Explicit language lookup |
| `ham_strings_set_language(lang)` | Runtime language selection hook for Control Panel |
| `HamStringCatalogHeader` / `HamStringCatalogEntry` | First on-disk `.LNG` catalog format |
| `HAML10N_VERSION=` | Embedded localization component version tag |

### Window manager

Every normal app should use these for its main window. All title bar styling, bevel buttons, active/inactive states, and future OS chrome changes propagate automatically.

| Field | Notes |
|---|---|
| `wnd_init(f, x, y, w, h, min_w, min_h)` | Init WindowFrame |
| `wnd_draw_frame(f, title)` | Draw title bar and chrome — **always use this, never roll your own** |
| `wnd_content_rect(f, inset, &x,&y,&w,&h)` | Get content area (use WINDOW_INSET=8) |
| `wnd_display_bounds(f, &x,&y,&w,&h)` | Full window bounds |
| `wnd_hit_test(f, x, y)` | Returns WINDOW_HIT_* (low-level; prefer `wnd_handle_pointer_down`) |
| `wnd_handle_pointer_down(f, x, y)` | Dispatch pointer-down on chrome; starts drag/resize, sets chrome_pressed |
| `wnd_handle_pointer_up(f, x, y)` | Dispatch pointer-up; ends drag/resize, returns hit type for close/maximize |
| `wnd_begin_drag(f, px, py)` | Start drag (called internally by wnd_handle_pointer_down) |
| `wnd_begin_resize(f, px, py)` | Start resize (called internally by wnd_handle_pointer_down) |
| `wnd_update_pointer(f, px, py)` | Call on pointer_move during drag/resize |
| `wnd_end_interaction(f)` | End drag or resize (called internally by wnd_handle_pointer_up) |
| `wnd_toggle_maximize(f)` | Toggle maximised state |

**Window chrome pattern (all apps must follow this):**

```c
/* pointer_down */
WindowHit hit = window_handle_pointer_down(&frame, x, y);
if (hit == WINDOW_HIT_CLOSE || hit == WINDOW_HIT_MAXIMIZE ||
    hit == WINDOW_HIT_TITLE || hit == WINDOW_HIT_RESIZE)
    return true;
if (hit != WINDOW_HIT_BODY) return false;
/* ... handle body clicks ... */

/* pointer_move */
if (window_update_pointer(&frame, x, y)) return true;

/* pointer_up */
WindowHit release = window_handle_pointer_up(&frame, x, y);
if (release == WINDOW_HIT_CLOSE)    { /* close app */ return true; }
if (release == WINDOW_HIT_MAXIMIZE) { window_toggle_maximize(&frame); return true; }
return release != WINDOW_HIT_NONE;
```

**Resizable content pattern (all resizable apps must follow this):**

- Recompute every content rectangle from `wnd_content_rect()` on each draw and
  each pointer/key hit-test pass.
- The primary pane/list/text area must grow and shrink with the window.
- Scrollbar height, visible row count, scroll limits, checkbox/list hit tests,
  status text, progress bars, and bottom buttons must use the current content
  width/height.
- Fixed inner white/text/list regions are allowed only in fixed-size dialogs or
  windows initialized as non-resizable.
- If a control cannot adapt safely yet, make the window fixed-size until the
  shared control exists.

### Standard OS scrollbar

Use `draw_scrollbar` for any scrollable content. Never implement a custom scrollbar — this ensures consistent look and keyboard/mouse behaviour across all apps.

`draw_scrollbar(x, y, height, total, visible, position, up_pressed, down_pressed)`

- `x, y, height` — position and total height of the 16-px-wide strip
- `total` — total scrollable units (e.g. line count)
- `visible` — units visible at once (determines thumb size)
- `position` — current scroll offset (0 = top)
- `up_pressed / down_pressed` — true while the respective arrow button is held

```c
g_host->draw_scrollbar(scroll_x, content_y, content_height,
    total_lines, visible_lines, scroll_offset,
    up_btn_pressed, down_btn_pressed);
```

Constants: `VGA_SCROLLBAR_WIDTH = 16`, `VGA_SCROLLBAR_ARROW_HEIGHT = 16`.

Scrollbar hit-testing for clicks:

```c
VgaScrollbarMetrics sb;
VgaScrollbarHit hit;

g_host->scrollbar_metrics(scroll_x, content_y, content_height,
    total_lines, visible_lines, scroll_offset, &sb);
hit = g_host->scrollbar_hit_test(&sb, pointer_x, pointer_y);
```

For automatic mouse-wheel support, expose the scrollable list as the app's
primary scroll area in `AppDescriptor`. The window manager will route wheel
events to the focused app. If your custom `handle_wheel()` is missing or
returns `false`, HamsterOS will use these two hooks to update the scroll
position automatically.

```c
static bool my_get_primary_scroll(AppScrollArea *out)
{
    if (total_lines <= visible_lines) return false;
    out->total = total_lines;
    out->visible = visible_lines;
    out->position = scroll_offset;
    out->wheel_step = 3; /* 0 also means the OS default of 3 */
    return true;
}

static bool my_set_primary_scroll(uint32_t position)
{
    uint32_t max = total_lines > visible_lines ? total_lines - visible_lines : 0;
    if (position > max) position = max;
    if (position == scroll_offset) return false;
    scroll_offset = position;
    return true;
}

static AppDescriptor g_desc = {
    /* required callbacks ... */
    .get_primary_scroll = my_get_primary_scroll,
    .set_primary_scroll = my_set_primary_scroll
};
```

Positive wheel deltas mean wheel-up. Apps with several independent scroll
areas or modal lists may still implement `handle_wheel()` directly; return
`true` only when the wheel actually changed scroll position or another visible
modal state. Boundary no-ops at the top/bottom must return `false` so the app
does not redraw or do work for a scroll that did not happen. The window manager
still consumes boundary wheel ticks that occur inside the focused app, so they
do not fall through to the desktop or another window. Return `false` to let the
primary scroll fallback run.

For Open/Save dialogs, do not implement dialog scrolling in each app. Route
modal wheel input through:

```c
return file_dialog_handle_wheel(&dialog, wheel, &dialog_config, context);
```

The shared File Dialog helper updates `page_start`, refreshes entries only when
the visible page changes, and returns `false` for top/bottom boundary no-ops.
Apps using the external host API get the same behavior through `fd_wheel`.

### Timing and audio

| Field | Signature | Notes |
|---|---|---|
| `pit_millis()` | `-> uint32_t` | Milliseconds since boot |
| `speaker_note_on(hz)` | `uint16_t hz` | Play square-wave tone on PC speaker |
| `speaker_note_off()` | none | Stop PC speaker tone |
| `sb16_card_present()` | `-> bool` | True if SoundBlaster 16 was detected at boot |
| `sb16_play_pcm(pcm, len, hz)` | `const uint8_t*, uint32_t, uint16_t` | Play 8-bit unsigned mono PCM non-blocking via DMA |
| `sb16_audio_stop()` | none | Halt DMA playback immediately |
| `sb16_opl3_note_on(midi_note)` | `uint8_t midi_note` | Play one OPL3 FM note using a MIDI note number |
| `sb16_opl3_note_off()` | none | Stop the active OPL3 FM note |

**SB16 audio notes for app authors:**

- Always call `sb16_card_present()` before using SB16 PCM or OPL3 services. Fall back to `speaker_note_on/off` on machines without a card.
- Playback is **non-blocking** — `sb16_play_pcm` returns immediately; DMA runs in background. Do not free the PCM buffer until playback finishes or you have called `sb16_audio_stop`.
- **Use sounds sparingly.** A single WAV file can easily consume 20–40 KB on a 1.44 MB floppy. Keep app sounds under 2 seconds. Prefer 11025 Hz, 8-bit unsigned mono; that yields ~11 KB per second.
- Call `sb16_audio_stop()` before starting a new sound so the previous DMA transfer does not overlap.
- For simple musical notes, prefer `sb16_opl3_note_on/off` over PCM samples; it costs much less floppy space.

### Misc

| Field | Notes |
|---|---|
| `prefs_sound()` | System confirmation beep |
| `serial_str(s)` | Debug string to serial port |
| `serial_char(ch)` | Single character to serial |
| `serial_dec16(v)` | int16_t as decimal to serial |
| `shell_files_changed()` | Notify shell to refresh file lists |
| `set_toolbar_hidden(bool)` | Retired compatibility no-op; retained to preserve ABI field offsets |
| `set_game_mode(bool)` | **Game Mode** — see section below |

### Game Mode

Game Mode gives an app exclusive ownership of the screen and input. Call it in `open()` and undo it in `close()`.

```c
void mm_port_open(void) {
    g_open = g_active = true;
    g_host->set_game_mode(true);   /* enter Game Mode */
    /* load game data, etc. */
}
void mm_port_close(void) {
    g_host->set_game_mode(false);  /* restore full OS UI */
    g_open = g_active = false;
}
```

**What `set_game_mode(true)` does:**

| Effect | Detail |
| ------ | ------ |
| Menu-bar header hidden | The 20 px OS header strip (menus, clock, LED) is not drawn; reclaimed for the game window |
| Exclusive keyboard input | All scancodes go **only** to the front WM app. `shell_handle_scancode` is completely bypassed — F1, Alt+F, Alt+H, Ctrl+Q, volume keys, and every other shell shortcut are suppressed. The app's `handle_scancode()` return value is not used as a fallback; the shell never sees the key. |
| Exclusive pointer input | Mouse movement, left-button gestures, and wheel events go only to the front WM app. Right-clicks and unhandled events are discarded; they cannot open or activate shell UI underneath the game. |
| Exclusive composition | Only the foreground app is composed while Game Mode is active. The desktop, File Manager, menus, task manager, companions, and background app windows cannot flash through a game redraw. |
| Foreground-only updates | Only the foreground app's `update()` callback runs. Other app animation and shell background work resume after Game Mode closes. |
| Screensaver disabled | The idle timer is not checked while game mode is active; the screensaver never fires during gameplay |

**Your `handle_scancode` in Game Mode:**

Return `true` for keys you handle and `false` for keys you don't — but note that returning `false` in Game Mode does **not** pass the key to the shell. It is simply discarded. The only way to exit Game Mode is to call `close()` (which should call `set_game_mode(false)`).

The same rule applies to pointer callbacks: returning `false` does not make the click available to the shell. Return `true` when a pointer event changed visible app state; Game Mode skips the repaint for an unchanged `false` result. Game Mode is a foreground-session policy, not a scheduler priority level. Kernel services and device I/O continue normally, while shell animation and other windowed app callbacks are paused.

**Recommended quit pattern** — catch ESC or whatever quit key the game uses:

```c
bool mm_port_scancode(uint8_t sc) {
    if (sc == 0x01) {          /* ESC — quit game */
        mm_port_close();
        return true;
    }
    /* ... game input handling ... */
    return true;               /* consume everything in game mode */
}
```

**Other ideas that combine well with Game Mode:**

- Position your `WindowFrame` at `(0, 0)` with `w=640, h=480` to cover the full screen (header is hidden so the top 20 px are available)
- Call `update()` returning `true` every frame for continuous animation
- Save game state in `close()` — this fires whether the user closes from your quit key or from a programmatic close
- `set_toolbar_hidden(bool)` remains only as a compatibility no-op for older compiled apps

### Shared transient workspace

| Field | Signature | Notes |
|---|---|---|
| `workspace_acquire` | `(size, owner, &out_size)` | Request exclusive scratch space for one transient operation |
| `workspace_release` | `(ptr)` | Release the shared scratch block when the operation is done |

Rules:

- Treat the workspace as a **temporary, exclusive loan**, not app-owned memory.
- Only use it for scratch data that can be discarded and recreated.
- Release it as soon as the blocking operation or calculation completes.
- If `workspace_acquire()` returns `NULL`, show a normal user-facing low-memory
  or busy status and abort that operation cleanly.
- Do **not** store live document state, window state, undo history, or any
  pointer that must survive beyond the current operation in the workspace.

### Shared clipboard

Plain text clipboard v1 is exposed through appended `HamsterHostAPI` fields:

| Field | Signature | Notes |
|---|---|---|
| `clipboard_set_text` | `(text, length, owner)` | Copies ASCII text into the OS clipboard; bounded and heap-backed |
| `clipboard_get_text` | `(buffer, max_size)` | Copies text out; returns the full clipboard size even if truncated |
| `clipboard_text_size` | `()` | Current text payload length |
| `clipboard_version` | `()` | Monotonic change counter for apps that cache clipboard state |
| `clipboard_owner` | `()` | Short owner label from the last writer |
| `clipboard_has_text` | `()` | True when a non-empty plain-text payload exists |

Keep clipboard use small and transient. Larger typed payloads are planned later;
v1 apps should treat it as plain text only.

---

## AppDescriptor fields

Return from `app_entry()`. Set unused fields to `NULL`.

| Field | Required | When called |
|---|---|---|
| `init()` | Yes | After app_entry() — init state, call wnd_init() |
| `open()` | Yes | User launches the app when `open_launch_at` is not implemented |
| `open_file(drive, name)` | No | Root-dir file association |
| `open_file_at(drive, cluster, name)` | No | File browser document double-click |
| `open_launch_at(drive, cluster, name)` | No | App launch with executable folder context; called instead of `open()` only when the `.APP` was packed with `--open-launch-at`, so store context and enter the normal open path. `name` is the `.APP` filename |
| `close()` | No | Programmatic close |
| `is_open()` | Yes | Every frame — return true while window exists |
| `is_active()` | Yes | Every frame — return true while window has focus |
| `has_modal()` | Yes | Every frame — true if a modal dialog is open |
| `set_active(bool)` | Yes | When another window gains focus |
| `handle_scancode(sc)` | Yes | Key press/release — return true if consumed |
| `handle_pointer_down/move/up(x,y)` | Yes | Mouse events — return true if consumed |
| `handle_menu_command(cmd)` | No | Menu item (uint32_t — cast to your enum) |
| `handle_edit_command(cmd)` | No | Edit menu / clipboard |
| `draw()` | Yes | Full window redraw |
| `draw_content()` | No | Partial redraw (content only) |
| `get_bounds(x,y,w,h)` | Yes | Window bounds for hit-testing and damage tracking |
| `update()` | No | Animation/audio tick — return true if redraw needed |
| `consume_redraw_request()` | No | Animation dirty flag poll |
| `handle_wheel(wheel)` | No | Signed wheel input; positive means wheel-up. Return `true` only when scroll/visible state changes; return `false` for top/bottom boundary no-ops |
| `get_primary_scroll(out)` | No | Describe the main scrollable list for automatic wheel support |
| `set_primary_scroll(position)` | No | Apply a clamped scroll position for automatic wheel support |
| `cursor_style_at(x,y)` | No | Return `VGA_CURSOR_RUNTIME_DEFAULT` for the normal pointer, or a transient `VGA_CURSOR_*` shape for the point under the mouse |

Use `cursor_style_at()` for local interaction affordances only. It should not
change the user's saved pointer preference; the window manager applies the
returned style only while drawing the cursor. Current transient styles include
busy, text/I-beam, pencil, eraser, fill, and crosshair.

### Generic OS Header Menus

Folder-loaded external apps do not install app-specific titles into the OS
header menu bar. The shell still exposes a generic File/Edit command surface
while the app is active and forwards those commands through the app descriptor:

| OS header action | Callback | Command ID |
|---|---|---|
| File > Load | `handle_menu_command` | `0` |
| File > Save | `handle_menu_command` | `1` |
| File > Save As | `handle_menu_command` | `2` |
| File > About | `handle_menu_command` | `3` |
| File > Quit | `handle_menu_command` | `4` |
| File > Export TXT | `handle_menu_command` | `5` |
| Edit > Undo | `handle_edit_command` | `0` |
| Edit > Find | `handle_edit_command` | `1` |
| Edit > Replace | `handle_edit_command` | `2` |
| Edit > Cut | `handle_edit_command` | `3` |
| Edit > Copy | `handle_edit_command` | `4` |
| Edit > Paste | `handle_edit_command` | `5` |
| Edit > Select All | `handle_edit_command` | `6` |
| Edit > Spell Check | `handle_edit_command` | `7` |
| Edit > Bold | `handle_edit_command` | `10` |
| Edit > Italic | `handle_edit_command` | `11` |
| Edit > Underline | `handle_edit_command` | `12` |

`File > Quit` and `Ctrl+Q` both route to command `4` for the foreground
folder-loaded app. The app should close its own window and return `true`; the
slot manager will notice `is_open() == false` and unload the app code. Apps may
still draw their own in-window menu bar for richer app-specific commands, but
common commands should share these generic IDs when possible.

---

## Writing a stubs file

Every OS function your app calls must be wrapped. The linker reports the
missing ones. See `apps/piano_stubs.c` for the full pattern:

```c
/* myapp_stubs.c */
#include "app_abi.h"
#include "window.h"
#include "vga.h"
#include <stdbool.h>
#include <stdint.h>

static HamsterHostAPI *g_host;
void myapp_stubs_set_host(HamsterHostAPI *h) { g_host = h; }

void vga_fill_rect(int16_t x, int16_t y, int16_t w, int16_t h, uint8_t c)
    { g_host->fill_rect(x, y, w, h, c); }
void vga_draw_text(int16_t x, int16_t y, const char *s, uint8_t c, uint8_t sc)
    { g_host->draw_text(x, y, s, c, sc); }
void vga_draw_border(int16_t x, int16_t y, int16_t w, int16_t h, uint8_t c)
    { g_host->draw_border(x, y, w, h, c); }

void window_init(WindowFrame *f, int16_t x, int16_t y, int16_t w, int16_t h,
                 int16_t mw, int16_t mh) { g_host->wnd_init(f,x,y,w,h,mw,mh); }
void window_draw_frame(const WindowFrame *f, const char *t)
    { g_host->wnd_draw_frame(f, t); }
void window_content_rect(const WindowFrame *f, int16_t i,
                          int16_t *x, int16_t *y, int16_t *w, int16_t *h)
    { g_host->wnd_content_rect(f, i, x, y, w, h); }
void window_display_bounds(const WindowFrame *f,
                            int16_t *x, int16_t *y, int16_t *w, int16_t *h)
    { g_host->wnd_display_bounds(f, x, y, w, h); }
WindowHit window_hit_test(const WindowFrame *f, int16_t x, int16_t y)
    { return g_host->wnd_hit_test(f, x, y); }
void window_begin_drag(WindowFrame *f, int16_t px, int16_t py)
    { g_host->wnd_begin_drag(f, px, py); }
void window_begin_resize(WindowFrame *f, int16_t px, int16_t py)
    { g_host->wnd_begin_resize(f, px, py); }
bool window_update_pointer(WindowFrame *f, int16_t px, int16_t py)
    { return g_host->wnd_update_pointer(f, px, py); }
void window_end_interaction(WindowFrame *f)  { g_host->wnd_end_interaction(f); }
void window_toggle_maximize(WindowFrame *f)  { g_host->wnd_toggle_maximize(f); }
```

---

## ABI stability contract

- `HamsterHostAPI` and `AppDescriptor` are **append-only**. New fields are
  always added at the end. Never reorder existing fields — doing so breaks
  every compiled .APP.
- HAMA `version` is incremented on breaking changes. Currently: 1.
- HAMA `flags` bit 0 means a bundled 16x16 VGA icon is present immediately
  after the header. Unknown flag bits should be treated as unsupported until
  a future version defines them.
- Shared structs (`WindowFrame`, `FileDialogState`) must be compiled from the
  OS source tree headers — never copy headers manually.
- The `name[8]` field in the HAMA header is the display name used by app
  launch surfaces and icon view. Set it with `mkapp.py --name MYAPP`
  (max 8 chars).

---

## Memory rules

- Static variables live in BSS, zeroed at load time.
- Persistent app state should still be static/BSS-backed in v1.
- General `heap_alloc()` / `heap_free()` are not part of the public app ABI.
- For large **transient** scratch work only, use the shared workspace API
  instead of inventing a private permanent buffer.
- BSS is freed when `is_open()` returns false. Do not store cross-open pointers.
- Total loaded app footprint = code + BSS. Current examples: Piano is about
  5 KB, Notepad about 23 KB, and Paint about 23 KB before their runtime heap
  buffers.
- The staging buffer used to load the .APP file is freed before `app_entry()`
  is called, so only your runtime region occupies heap.

---

## Conventions

- **Prefix all symbols** with your app name. The linker combines all objects;
  duplicate names cause link errors.
- **No libc.** Use only `<stdbool.h>`, `<stdint.h>`, `<stddef.h>`.
- **No direct I/O.** Never access VGA memory, I/O ports, or kernel globals.
  Every OS interaction goes through `HamsterHostAPI`.
- **Uppercase 8.3 filenames** on the FAT12 filesystem.
- **One USER app, game, or tool slot at a time.** Loading another closes the
  current one and releases the previous `.APP` image.
- **Show load feedback before blocking reads.** For file-manager launches and lazy-loaded
  system apps, draw a loading toast before `fat_load_file()` or
  `app_loader_load()` begins. The file manager does this automatically for
  `.APP` files double-clicked in icon or list view.
- **Fail visibly and return control.** If a load cannot complete, show a
  user-facing error dialog and return to the desktop or file manager. Do not
  spin, panic, or leave the loading toast as the last painted state.
- **Use C11 designated `AppDescriptor` initializers.** Do not use positional
  callback lists. New ABI hooks are appended over time, such as wheel,
  primary-scroll, and cursor-style callbacks. Designated initializers keep old
  apps valid and prevent one shifted field from turning a good app into an
  invalid or crashing one.
- **Provide `close()` when the app owns heap buffers or hardware state.** The
  slots call it before replacing a loaded app/tool, and lazy system-app loaders
  use it to make teardown idempotent before releasing the `.APP` image.

---

## File manager icon view, `.ICO` sidecars, and bundled icons

The file manager supports two view modes per window: **By Name** (list) and **By Icon** (grid).
Users toggle between them via **VIEW → BY NAME / BY ICON** in the top menu bar.

### Generic file-type icons

`drivers/vga.c` provides a centralized icon library via:

```c
void vga_draw_file_icon(FileIconKind kind, int16_t x, int16_t y, uint8_t color);
```

`FileIconKind` values (from `drivers/vga.h`):

| Constant | Shown for |
| --- | --- |
| `FILE_ICON_FOLDER` | Directories |
| `FILE_ICON_APP` | `.APP` files (fallback when no bundled icon) |
| `FILE_ICON_TEXT` | `.TXT`, `.HWD`, `.RTF`, `.HLP`, `.INI`, and `.CFG` files |
| `FILE_ICON_IMAGE` | `.BMP`, `.GIF`, `.PNG`, and `.ICO` files |
| `FILE_ICON_EXEC` | `.COM`, `.EXE`, and `.BIN` files |
| `FILE_ICON_SCRIPT` | `.BAT` and `.SYS` files |
| `FILE_ICON_ARCHIVE` | `.ZIP` files |
| `FILE_ICON_UNKNOWN` | Everything else |

### Preferred fast path: `.ICO` sidecars

For built-in apps/tools/games shipped on the floppy, HamsterOS prefers a
separate `NAME.ICO` sidecar next to `NAME.APP`. This keeps folder browsing fast:
the file manager can batch-load a fixed 256-byte icon file without touching the
app binary at all.

Use sidecars for anything that appears in normal folder listings on the shipping
disk. This is the default pattern for `/APPS`, `/GAMES`, `/TOOLS`, `/USER`, and
desktop shortcuts.

### Bundled `.APP` icons

When an `.APP` file has `HAMA_FLAG_HAS_ICON` set in its header, HamsterOS can
still read and display that icon by loading only the fixed header/icon block via
`app_loader_read_icon()`. The full app code is never loaded just for icon
display.

This path is best treated as compatibility or metadata support, not the primary
folder-browsing strategy. If you expect users to browse a directory full of apps,
ship `.ICO` sidecars so icon view stays snappy.

Icon data format: 256 bytes, 16×16 pixels, one byte per pixel (VGA palette index).
Color 0 is treated as transparent by `vga_draw_palette_icon()`.

To include an icon in your app, pass `--icon assets/myapp_icon.bin` to `mkapp.py`:

```sh
python3 tools/mkapp.py --elf myapp.elf --name MYAPP --icon assets/myapp_icon.bin --output MYAPP.APP
```

The icon binary must be exactly 256 bytes (16×16 VGA palette indices, row-major).
See existing game apps (`assets/snake_icon.bin` etc.) as references.

---

## Existing apps as reference implementations

| App | Source | Format | Notes |
|---|---|---|---|
| Notepad | `apps/notepad.c` + `apps/notepad_stubs.c` | `/APPS/NOTEPAD.APP` | File I/O, file dialog, complex state |
| Paint | `apps/paint.c` + `apps/paint_stubs.c` | `/APPS/PAINT.APP` | External system app, heap canvas, close hook |
| Piano | `apps/piano.c` + `apps/piano_stubs.c` | `/USER/PIANO.APP` | Audio (speaker), animation tick |
| Slots | `apps/slot_machine.c` + `apps/slot_stubs.c` | `/USER/SLOTS.APP` | Animation, consume_redraw_request |
| Games | `apps/*_gentry.c` + `apps/*_gstubs.c` | `/GAMES/*.APP` | Bundled 16x16 icons via HAMA `HAS_ICON` |
| Disk Copier | `apps/diskcopier.c` + `apps/diskcopier_tool_stubs.c` | `/TOOLS/DISKCOPY.APP` | Floppy driver API stubs |
| Audio Player | `apps/waveplayer.c` + `apps/cdplayer.c` | `/TOOLS/AUDPLAY.APP` | File audio plus analog/digital CD transport and shared spectrum |
| Floppy Diag | `apps/floppydiag.c` + `apps/floppydiag_tool_stubs.c` | `/TOOLS/FLOPDIAG.APP` | Full floppy + FAT + shell stubs |
| CD ROM Diag | `apps/cdromdiag.c` + `apps/cdromdiag_tool_stubs.c` | `/TOOLS/CDDIAG.APP` | CDROM diagnostic stubs |
| IDE Diag | `apps/idediag.c` + `apps/idediag_tool_stubs.c` | `/TOOLS/IDEDIAG.APP` | IDE diagnostic stubs |
| Format | `apps/format.c` + `apps/format_tool_stubs.c` | `/TOOLS/FORMAT.APP` | FAT format + floppy stubs |
| GFX Diag | `apps/gfxdiag.c` + `apps/gfxdiag_tool_stubs.c` | `/TOOLS/GFXDIAG.APP` | Multi-mode VGA benchmark |

---

## HamsterHostAPI — extended fields (tool apps, June 2026)

The following fields were appended after `prefs_failure_sound` to support the
tool apps in `/TOOLS/`. All new fields follow the same append-only rule.

| Field | Maps to | Used by |
|---|---|---|
| `draw_text_bold` | `vga_draw_text_bold` | All tool apps |
| `present_framebuffer` | `vga_present_framebuffer` | GFX Diag |
| `get_video_mode` | `vga_current_video_mode` | GFX Diag |
| `pit_accumulate` | `pit_accumulate` | Floppy Diag, GFX Diag |
| `serial_hex32` | `serial_write_hex32` | Floppy Diag |
| `fat_delete` … `fat_file_trace` | FAT extension functions | Floppy Diag, Format |
| `shell_set_boot_ready` … `shell_set_cdrom_ready` | Shell state notifications | Floppy Diag, Format, CD ROM Diag |
| `kernel_service` | `kernel_service` | Disk Copier |
| `floppy_a_ready` … `floppy_last_failure` | Floppy driver functions | Disk Copier, Floppy Diag, Format |
| `cdrom_present` … `cdrom_eject` | CDROM driver functions | CD Player, CD ROM Diag |
| `ide_diag` | `ide_run_diagnostic` | IDE Diag |
| `ata_drive_present`, `ata_read_raw` | ATA raw sector access | Partition Table Viewer |
| `sb16_card_present`, `sb16_play_pcm`, `sb16_audio_stop`, `sb16_opl3_note_on/off` | SoundBlaster 16 | Piano (OPL3), any app that wants digital audio |

---

## Hardware access and driver architecture

### How drivers work in HamsterOS

All drivers in `drivers/` are **compiled directly into the kernel binary** (`hamster-dos.bin`). There is no dynamic driver loading. External `.APP` files cannot install new kernel drivers — they access hardware exclusively through the `HamsterHostAPI` function table passed to `app_entry()`.

```text
Kernel binary (hamster-dos.bin)
  └─ drivers/floppy.c   ──┐
  └─ drivers/ata.c       ├─ Storage Registry (drivers/storage.*)
  └─ drivers/atapi.c     │    storage_device_count() / storage_get_device_info()
  └─ drivers/cdrom.c    ──┘    storage_read_sector512() / storage_write_sector512()
  └─ drivers/sb16.c
  └─ drivers/vga.c            ↑ exposed to external apps via HamsterHostAPI
  └─ ...                      ↓
External .APP file
  └─ myapp_stubs.c  ──── calls g_host->sb16_card_present(), g_host->floppy_a_ready(), etc.
```

If your app needs hardware that isn't exposed yet, the path is: add the kernel function → append a new field to `HamsterHostAPI` (always at the end) → add a stub in your app's stubs file. Never reorder existing fields.

---

### Driver registry

The driver registry (`drivers/driver_model.h`) provides a read-only view of every driver detected at boot.

```c
/* Enumerate all drivers */
uint8_t count = driver_registry_count();  /* via g_host->... not exposed yet — use tool-app APIs directly */
DriverInfo info;
for (uint8_t i = 0; i < count; i++) {
    driver_registry_get(i, &info);
    /* info.id, info.name, info.driver_class, info.present, info.instance_count */
}
```

`DriverClass` values: `DRIVER_CLASS_STORAGE`, `DRIVER_CLASS_INPUT`, `DRIVER_CLASS_AUDIO`, `DRIVER_CLASS_VIDEO`, `DRIVER_CLASS_TIMER`, `DRIVER_CLASS_SYSTEM`.

Capability flags (bitfield in `info.capability_flags`): `DRIVER_CAP_BLOCK_IO`, `DRIVER_CAP_REMOVABLE`, `DRIVER_CAP_READ_ONLY`, `DRIVER_CAP_POINTER`, `DRIVER_CAP_KEYBOARD`, `DRIVER_CAP_PCM_OUTPUT`, `DRIVER_CAP_FRAMEBUFFER`, `DRIVER_CAP_RTC_CLOCK`, `DRIVER_CAP_TIMER_TICKS`, `DRIVER_CAP_DIAGNOSTIC`.

> The driver registry is informational only. It does not route I/O — use the storage registry or direct hardware APIs for actual reads/writes.

---

### Storage registry

The storage registry (`drivers/storage.h`) enumerates every block device and abstracts reads/writes behind a single index-based API. The FAT layer uses this internally; apps can use it directly for raw sector access.

```c
/* StorageDeviceInfo fields */
typedef struct {
    bool present;           /* device detected */
    bool media_present;     /* removable media loaded (floppy/CD-ROM) */
    bool read_only;         /* write-protected */
    bool removable;         /* can eject/swap media */
    StorageDeviceClass device_class;   /* FLOPPY / HARDDISK / CDROM */
    StorageBackend backend;            /* FLOPPY / ATA / ATAPI / EXTERNAL */
    uint8_t backend_index;  /* drive ID within backend (0–3) */
    uint8_t instance_number;
    uint16_t block_size;    /* bytes per sector: 512 for floppy/HDD, 2048 for CD */
    const char *controller_name;  /* "IDE Primary", "Floppy Controller", … */
    const char *device_name;      /* "Primary Master", "Floppy Drive A", … */
} StorageDeviceInfo;
```

**Enumerate and read:**

```c
uint8_t n = storage_device_count();
for (uint8_t i = 0; i < n; i++) {
    StorageDeviceInfo info;
    if (!storage_get_device_info(i, &info)) continue;
    if (!info.present || !info.media_present) continue;
    /* read sector 0 */
    uint8_t buf[512];
    if (storage_read_sector512(i, 0u, buf)) {
        /* buf contains first 512 bytes of the device */
    }
}
```

> `storage_read/write_sector512` is not yet exposed via `HamsterHostAPI`. Use the specific driver APIs below for now.

---

### Hardware APIs available to apps

All hardware access goes through `HamsterHostAPI`. Use these via your stubs file:

**Floppy:**

```c
g_host->floppy_a_ready()                    /* true if drive A has media */
g_host->floppy_b_ready()                    /* true if drive B has media */
g_host->floppy_a_read(lba, buf)             /* read 512-byte sector */
g_host->floppy_a_write(lba, buf)
g_host->floppy_b_read(lba, buf)
g_host->floppy_b_write(lba, buf)
g_host->floppy_batch_begin(drive)           /* hold motor on for multi-sector ops */
g_host->floppy_batch_end()
g_host->floppy_cmos_types()                 /* CMOS nibbles for A/B drive types */
g_host->floppy_get_timing() / set_timing()  /* FloppyTimingMode */
g_host->floppy_last_failure(out)            /* FloppyFailureInfo */
```

**CD-ROM / ATAPI audio:**

```c
g_host->cdrom_present()                     /* any CD-ROM controller found */
g_host->cdrom_audio_ok()                    /* true = ATAPI audio transport available */
g_host->cdrom_read_toc(out)                 /* AtapiToc — track list and lengths */
g_host->cdrom_play(sm,ss,sf, em,es,ef)      /* play MSF range */
g_host->cdrom_pause(resume)
g_host->cdrom_stop()
g_host->cdrom_status(out)                   /* AtapiAudioStatus */
g_host->cdrom_eject()
```

**ATA raw sector access (primary master only):**

```c
g_host->ata_drive_present()
g_host->ata_read_raw(lba, buf)              /* 512-byte sector, no partition translation */
g_host->ata_write_raw(lba, buf)
g_host->ata_sector_count_raw()              /* total sectors on device */
```

**SoundBlaster 16:**

```c
g_host->sb16_card_present()                 /* SB16 detected at boot */
g_host->sb16_get_base_port()                /* I/O base (0x220, 0x240, …) */
g_host->sb16_get_irq_num()                  /* IRQ number */
g_host->sb16_play_pcm(buf, len, hz)         /* 8-bit unsigned mono PCM */
g_host->sb16_play_wav_buf(wav, sz)          /* play a WAV buffer from memory */
g_host->sb16_audio_stop()
g_host->sb16_opl3_note_on(midi_note)        /* FM synthesis via YMF262 */
g_host->sb16_opl3_note_off()
```

**PC Speaker:**

```c
g_host->speaker_note_on(hz)                 /* start tone at frequency */
g_host->speaker_note_off()
```

**LPT1 printer:**

```c
g_host->printer_info(&info)                 /* BIOS port and decoded live status */
g_host->printer_status_text(info.status)    /* user-facing status text */
g_host->printer_test_page()                 /* bounded CRLF raw-text test page */
```

The built-in LPT path never probes by writing to the port. It uses the BIOS
Data Area address, times out instead of waiting forever for a busy printer,
and currently supports raw text rather than graphics or background spooling.
Respect `g_host->prefs_get_printer_enabled()` before presenting output actions.

**Gameport joystick:**

```c
g_host->joystick_read(&state)               /* centred X/Y, raw timing, two buttons */
g_host->joystick_calibrate()                 /* capture the current stick centre */
```

Gameport sampling is bounded to keep an absent or disconnected analogue stick
from hanging the desktop. Check the return value and `state.present`; respect
`g_host->prefs_get_gameport_enabled()` before polling. Games should poll once
per update tick, not from a tight blocking loop.

---

### Writing a hardware tool app — checklist

1. Add stubs for every `g_host->` field you use in `myapp_stubs.c` (see `apps/piano_stubs.c`).
2. Check `g_host->sb16_card_present()` / `g_host->floppy_a_ready()` / `g_host->cdrom_present()` before using a device — never assume hardware is present.
3. Call `g_host->kernel_service()` inside long I/O loops to keep the mouse cursor moving.
4. For CD-ROM reads through FAT, use `g_host->fat_load` with the mounted CD-ROM drive. The FAT layer routes ATAPI and external storage devices. Only use the direct ATAPI APIs if you are writing a diagnostic.
5. To add a new hardware API: add the kernel function, append one field to `HamsterHostAPI` in `kernel/app_abi.h`, wire it in `kernel/app_loader.c`, add a stub. Never insert between existing fields.

---

## Writing kernel drivers (.DRV)

HamsterOS supports external loadable kernel drivers via the HAMD v1 format.
Unlike `.APP` files (user-space apps), `.DRV` files are loaded early in boot and
run as trusted kernel-level code with direct port I/O access.

**Drop a `.DRV` into `/DRIVERS/` on the floppy and reboot — the driver is live.**

The complete driver writing guide is in **`docs/HAMSTEROS_DRIVER_SDK.md`**,
covering:

- How HAMD differs from HAMA (magic, entry point, trusted execution, `tools/driver.ld`)
- `DriverHostAPI` — the host services table drivers receive (port I/O, heap, serial)
- `DriverDescriptor` — what your driver returns (storage, audio, and input callbacks)
- Two-file structure (`mydrv_entry.c` + `mydrv.c`) and build recipe
- `StorageDeviceInfo` fields for storage-class drivers
- Safety rules and the `apps/example_driver/nulldisk` reference implementation

---

## Validating and repairing an invalid `.APP`

Use this checklist when an extras-disk app such as `MM.APP` shows as invalid,
fails immediately after launch, or was rebuilt outside the main OS tree.

### File format checks

- The first four bytes must be HAMA: `48 41 4D 41` in the file, represented as
  magic `0x414D4148` in the little-endian loader.
- `version` must be `1`.
- `flags` may only use known bits: `HAS_ICON=0x0001`, `COMPRESSED=0x0002`,
  `ALIAS=0x0004`, `OPEN_LAUNCH_AT=0x0008`, `APP_MENUS=0x0010`,
  `RIGHT_CLICK=0x0020`, and `HIGH_MEMORY=0x0040`.
- Header size is 32 bytes: magic, version, flags, code size, BSS size, entry
  offset, relocation count, and an 8-byte app name.
- `entry_offset` must be less than `code_size`.
- Every relocation offset must be less than `code_size - 3`.
- If `HAS_ICON` is set, the 256-byte icon block comes before code or packed
  code data.
- If `COMPRESSED` is set, the 32-bit packed size follows the optional icon
  block, and the packed blob must end before the relocation table.
- If `ALIAS` is set, the payload is an ASCII target path such as
  `/GAMES/MM.APP`; an alias is not executable app code.

### Build checks

- Link with `ld -T tools/app.ld -melf_i386 --emit-relocs -nostdlib ...`.
  Missing `--emit-relocs` is the most common cause of descriptor callbacks
  pointing at unmapped addresses.
- Pack with `python3 tools/mkapp.py --elf mm.elf --name MM --output MM.APP`.
- Use `--open-launch-at` only when the app implements `open_launch_at()` and
  needs the executable folder as an asset base.
- Do not link libc. If you see unresolved `memset`, `memcpy`, division helpers,
  or file APIs, add a tiny local helper or route through `HamsterHostAPI`.
- `app_entry(HamsterHostAPI *host)` must return a non-NULL `AppDescriptor *`.
- Required callbacks must be non-NULL: `init`, `open`, `is_open`, `is_active`,
  `has_modal`, `set_active`, `handle_scancode`, `handle_pointer_down`,
  `handle_pointer_move`, `handle_pointer_up`, `draw`, and `get_bounds`.
- If the app has both logic and stub files, initialize both host pointers:
  `mm_set_host(host);` and `mm_stubs_set_host(host);`.
- Use designated `AppDescriptor` initializers so newer appended hooks do not
  shift older callback fields.

### Extras-image checks

- Rebuild the extras image after replacing the app. A stale
  `hamster_extras.img` can make a fixed app look broken.
- Put sidecar assets beside the executable when the app expects local assets,
  and pack with `--open-launch-at` if it needs that directory context.
- If the app is exposed through a shortcut, confirm the alias target path points
  at the real app on the same image.
- Launch from B: and watch serial output. A healthy load shows
  `app_loader: loaded MM.APP` followed by the app-specific open/init logs, with
  no fault or panic output.

---

## Loader troubleshooting notes

- If an external app soft-crashes immediately after `app_loader: loaded NAME.APP`,
  inspect descriptor relocations first. `AppDescriptor` contains function
  pointers and must have `R_386_32` relocations applied like any other static
  pointer table.
- If an icon-bearing app crashes before `app_entry()`, verify the loader skips
  the optional 256-byte icon block before copying code.
- If a window appears at `(0,0)` with broken bounds or does not open, confirm
  the owner slot/loader calls `desc->init()` before `desc->open()`.
- If an app works as built-in code but not as `.APP`, check the stubs file for
  any direct OS calls that were not routed through `HamsterHostAPI`.
- If the screen stays at `Loading NAME`, the loader is probably blocked inside
  the disk read path. Check serial output for `app_loader: header load failed`,
  `app_loader: load failed`, the FAT trace, and the byte/truncation counters.
- If a load fails cleanly, the expected user experience is an error dialog such
  as "File not found.", "Invalid application file.", "Not enough memory to open
  application.", or "Application file could not be read."

The quickest runtime smoke test is to boot `build/hamster.img`, launch an app,
and watch serial output for `app_loader: loaded NAME.APP` followed by the
app-specific loader message, with no fault/panic output afterward.

---

*HamsterOS — 32-bit protected-mode OS for 386/486 hardware.*
*App Developer Guide — July 2026.*
