/*
  Win32 GUI for the PenguinMod/Scratch tsunami simulation C port.

  Build on Windows with MinGW-w64:
      gcc -O3 -std=c11 tsunami_gui_win32.c tsunami_core.c -lm -lgdi32 -lcomdlg32 -o tsunami_gui.exe

  Run:
      tsunami_gui.exe tsunami_data.bin

  Controls:
      Left click      Add a source point at the clicked grid cell
      Left drag       Paint/draw source; drag direction sets fault strike and rupture delay
      Ctrl+mouse wheel Zoom in/out
      Ctrl+left drag   Pan map view
      Shift hover stations, release Assign forecast-zone id/name to all touched stations
      Space           Pause / play
      N               Advance one step while paused
      R               Reset time to 0, preserving source points
      C               Clear all source points and reset
      W               Cycle live waves / max wave / max+arrival layer
      O               Save max-wave PNG image
      L               Load source_points.csv
      S               Toggle finite-fault stamp mode (Ctrl+S saves station + forecast-zone CSV)
      + / -           Change source slip amount (plain=0.10m, Shift=0.50m, Ctrl=0.01m)
      [ / ]           Change source delay
      Q               Toggle source profile: flat brush / curved reverse-fault
      D               Flip reverse-fault subsidence side: right/left of strike
      Strike          Removed from manual controls; mouse progress direction sets strike
      Settings        Adjust fault width in km, dip angle, strike-slip mix, rupture velocity
      7 / 8           Decrease / increase wave overlay opacity
      I               Toggle strike-slip right/left sense
      9 / 0           Decrease / increase subsidence ratio
      Up / Down       Change speed: simulation steps per screen frame
      V / H           Toggle vertical/horizontal display flip
      G               Toggle 1-cell coastline guard on/off
      F               Toggle low-flicker drawing mode
      M               Toggle source markers on/off
      P               Toggle coastal height markers on/off
      K               Coastal marker mode: peak-so-far / live current height
      A               Toggle stable coastal partial-reflection absorption on/off
      3 / 4           Decrease / increase coastal boundary energy loss
      Shift+3 / 4     Fine tune coastal boundary energy loss
      B               Toggle selected time-series station marker display
      Ctrl+D, then B   Toggle bathymetry debug base map
      Ctrl+D, then L   Toggle visual land/sea inversion
      Ctrl+D, then R   Reset rupture delay cursor
      5 / 6           Decrease / increase wave display coefficient
      Mouse hover     Show exact observation-point height and station_id/zone info
      T               Select hovered observation point for time-series recording
      Shift+T         Add hovered observation point to multi-station time-series recording
      Y               Save selected observation point(s) time-series CSV + SVG graph
      E               Advanced source-node tune mode: click node, +/- slip with auto uplift, [/ ] delay, arrows move
      Stamp mode      S toggle, left click stamp, N/Enter finish fault + auto subsidence, G regen subsidence, Tab cycle, Z/X strike, [/ ] delay, J/K length, < > width+length
      Node tune       Ctrl-click toggle, Shift-click add, Shift-drag box select, A all, C/Esc clear, Tab primary/next
      Node tune       PgUp/PgDn slip with auto uplift, F footprint-only, U copy current brush, Del remove selected
      Japanese zone text uses a CJK font to prevent square-box glyphs
      Color settings auto-save to tsunami_color_settings.ini next to the EXE
      JESIM WebSocket relay outbox writes jesim_ws_outbox.jsonl for Scratch/TurboWarp bridge
      coast_override.csv is loaded automatically as the default corrected coast mask
      Right-edge artificial observation line is hidden/ignored without shifting station_id mapping
      Absorbing boundary damping is strengthened to suppress reflected waves
      Esc             Quit
*/

#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <windowsx.h>
#include <commdlg.h>

#include <math.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdarg.h>
#include <wchar.h>

#include "tsunami_core.h"

#ifndef TSUNAMI_PI
#define TSUNAMI_PI 3.14159265358979323846f
#endif

#define PANEL_W 320
#define TIMER_ID 1
#define TIMER_MS 16

#define PANEL_BUTTON_TOP 330
#define PANEL_BUTTON_H 28
#define PANEL_BUTTON_GAP 8

#define WAVE_LAYER_LIVE 0
#define WAVE_LAYER_MAX_POSITIVE 1
#define WAVE_LAYER_MAX_WITH_ARRIVAL 2

#define ID_BTN_RUN_PAUSE 1001
#define ID_BTN_STEP 1002
#define ID_BTN_RESET 1003
#define ID_BTN_CLEAR 1004
#define ID_BTN_LOAD_SOURCE 1005
#define ID_BTN_SAVE_RESULT 1006
#define ID_BTN_SAVE_GRAPH 1007
#define ID_BTN_SETTINGS 1008
#define ID_BTN_KEY_GUIDE 1009

#define ID_SET_LBL_SOURCE 2001
#define ID_SET_LBL_FAULT 2002
#define ID_SET_LBL_DISPLAY 2003
#define ID_SET_LBL_COAST 2004
#define ID_SET_LBL_SIM 2005
#define ID_SET_UPLIFT_DOWN 2010
#define ID_SET_UPLIFT_UP 2011
#define ID_SET_UPLIFT_FINE_DOWN 2012
#define ID_SET_UPLIFT_FINE_UP 2013
#define ID_SET_DELAY_DOWN 2014
#define ID_SET_DELAY_UP 2015
#define ID_SET_BRUSH_DOWN 2016
#define ID_SET_BRUSH_UP 2017
#define ID_SET_PROFILE_TOGGLE 2018
#define ID_SET_STRIKE_DOWN 2019
#define ID_SET_STRIKE_UP 2020
#define ID_SET_STRIKE_FINE_DOWN 2021
#define ID_SET_STRIKE_FINE_UP 2022
#define ID_SET_SUBSIDE_TOGGLE 2023
#define ID_SET_RATIO_DOWN 2024
#define ID_SET_RATIO_UP 2025
#define ID_SET_SPEED_DOWN 2026
#define ID_SET_SPEED_UP 2027
#define ID_SET_COAST_TOGGLE 2028
#define ID_SET_COAST_LOSS_DOWN 2029
#define ID_SET_COAST_LOSS_UP 2030
#define ID_SET_SOURCE_MARKERS 2031
#define ID_SET_COAST_MARKERS 2032
#define ID_SET_COAST_MODE 2033
#define ID_SET_RINGS 2034
#define ID_SET_FLIP_X 2035
#define ID_SET_FLIP_Y 2036
#define ID_SET_COAST_GUARD 2037
#define ID_SET_DOUBLE_BUFFER 2038
#define ID_SET_CLOSE 2039
#define ID_SET_STRIKE_SLIP_TOGGLE 2040
#define ID_SET_RUPTURE_DELAY_RESET 2041
#define ID_SET_BILATERAL_TOGGLE 2042
#define ID_SET_EDIT_UPLIFT 2050
#define ID_SET_EDIT_DELAY 2051
#define ID_SET_EDIT_WIDTH 2052
#define ID_SET_EDIT_STRIKE 2053
#define ID_SET_EDIT_RATIO 2054
#define ID_SET_EDIT_SPEED 2055
#define ID_SET_EDIT_COAST_LOSS 2056
#define ID_SET_APPLY_NUMERIC 2057
#define ID_SET_EDIT_DIP 2058
#define ID_SET_EDIT_STRIKE_SLIP 2059
#define ID_SET_EDIT_WAVE_ALPHA 2060
#define ID_SET_EDIT_RUPTURE_SPEED 2061
#define ID_SET_EDIT_TOP_LAT 2062
#define ID_SET_EDIT_STRIKE_SMOOTH 2063
#define ID_SET_EDIT_SOURCE_DEPTH 2064
#define ID_SET_EDIT_JESIM_SYNC_DELAY 2065
#define ID_SET_TAB_MAIN 2070
#define ID_SET_TAB_COLORS 2071
#define ID_SET_TAB_PRESET 2079
#define ID_SET_EDIT_CELL_COLORS 2072
#define ID_SET_EDIT_COAST_COLORS 2073
#define ID_SET_APPLY_COLORS 2074
#define ID_SET_RESET_COLORS 2075
#define ID_SET_CELL_CURVE_TOGGLE 2076
#define ID_SET_COAST_CURVE_TOGGLE 2077
#define ID_SET_LBL_COLOR_STATUS 2078
#define ID_SET_SAVE_PRESET 2080
#define ID_SET_LOAD_PRESET 2081
#define ID_SET_SHOW_ZONE66 2082
#define ID_SET_SAVE_STATION_ZONE_CSV 2083
#define ID_SET_LBL_PRESET_STATUS 2084
#define ID_SET_EXPORT_ZONE_ASSIGNMENTS 2085
#define ID_ZONE_ASSIGN_EDIT_ID 2090
#define ID_ZONE_ASSIGN_EDIT_NAME 2091
#define ID_ZONE_ASSIGN_APPLY 2092
#define ID_ZONE_ASSIGN_CANCEL 2093

// The exported PenguinMod grid uses the opposite Y direction from normal
// screen coordinates. We keep the simulation grid unchanged and flip only
// GUI drawing/mouse projection so Japan appears upright.
#define DISPLAY_FLIP_X_DEFAULT 0
#define DISPLAY_FLIP_Y_DEFAULT 1

static GridData g_grid;
static Simulation g_sim;
static SourceList g_sources;
static uint32_t *g_pixels = NULL;
static uint32_t *g_base_pixels = NULL;  // cached static land/sea/bathymetry background
static BITMAPINFO g_bmi;

static int g_time_s = 0;
static int g_dt_s = DEFAULT_DT_SECONDS;
static int g_running = 0;
static int g_steps_per_frame = 2;
static float g_new_source_height_m = 1.0f;
static int g_new_source_delay_s = 30;
static int g_mouse_grid_x = -1;
static int g_mouse_grid_y = -1;
static int g_mouse_screen_x = -1;
static int g_mouse_screen_y = -1;
static int g_hover_station_i = -1;

/* Advanced source-node fine tuning.
   This mode is intentionally keyboard/cell based so the normal drawing workflow
   stays simple.  Press E, click a source marker, then adjust the selected node. */
static int g_node_tune_mode = 0;
static int g_selected_source_i = -1;
static uint8_t *g_node_tune_selected = NULL;
static size_t g_node_tune_selected_capacity = 0;
static int g_node_tune_box_select_active = 0;
static int g_node_tune_box_start_x = 0;
static int g_node_tune_box_start_y = 0;
static int g_node_tune_box_cur_x = 0;
static int g_node_tune_box_cur_y = 0;

/* v40 finite-fault stamp mode.
   This is the first step toward a USGS-style finite-fault workflow: each click
   places a rectangular fault patch, new patches in the same fault are
   automatically bridged to the nearest existing stamp, and each fault group freezes strike/dip/depth/width
   from its first stamp so later global setting changes do not rewrite already
   painted patches.  v45 adds per-patch plane-following depth: every stamp center is projected
   onto the first stamp's strike/down-dip plane, so down-dip stamp rows naturally get deeper
   instead of forming repeated shallow "solar panel" strips.
   v47 adds automatic down-dip subsidence generation for finite-fault stamps.
   Loading a v46 preset containing only the stamped uplift/footprint nodes now
   reconstructs the negative subsidence lobe automatically.
   v48 changes that generator from per-patch lobes to a continuous-fault
   secondary deformation field: direct stamp cells are interpolated into one
   filled rectangular/elliptic fault ribbon per fault_id, then weak Okada-style
   second-order uplift/subsidence shoulders are generated from that single
   ribbon so gaps between stamps are not turned into artificial subsidence. */
#define MAX_STAMP_FAULT_GROUPS 128
typedef struct {
    int id;
    int completed;
    int has_stamp;
    int last_x;
    int last_y;
    int origin_x;
    int origin_y;
    int subsidence_side;
    float strike_deg;
    float dip_deg;
    float top_depth_km;
    float width_km;
    float length_km;
    int delay_s;
    int stamp_count;
} StampFaultGroup;

static int g_fault_stamp_mode = 0;
static StampFaultGroup g_stamp_faults[MAX_STAMP_FAULT_GROUPS];
static int g_stamp_fault_count = 0;
static int g_current_stamp_fault = -1;
static int g_next_stamp_patch_id = 1;
static float g_fault_stamp_length_km = 40.0f;
static int g_active_stamp_fault_id = -1;
static int g_active_stamp_patch_id = 0;

/* v43 source-edit undo stack.
   Ctrl+Z restores the previous source-node/stamp-group state.  This is aimed at
   expensive editing actions (brush strokes, stamp patches, node tune moves or
   deletes, source CSV/preset loads, and clear-all).  Runtime wave fields are
   rebuilt from the restored source list after undo. */
#define SOURCE_UNDO_DEPTH 32
typedef struct {
    SourceSpec *items;
    size_t count;
    StampFaultGroup stamp_faults[MAX_STAMP_FAULT_GROUPS];
    int stamp_fault_count;
    int current_stamp_fault;
    int next_stamp_patch_id;
    int selected_source_i;
    float rupture_delay_cursor_s;
    float drag_start_delay_cursor_s;
    int active_paint_delay_override_s;
    char label[96];
} SourceUndoSnapshot;

static SourceUndoSnapshot g_source_undo_stack[SOURCE_UNDO_DEPTH];
static int g_source_undo_count = 0;


/* Temporary forecast-zone assignment tool.
   Hold Shift over an observation marker, then release Shift to assign an id/name.
   After the assignment CSV is verified and baked into the project, this temporary
   interactive assignment feature can be removed. */
#define MAX_STATION_ZONE_ASSIGNMENTS 12000
#define ZONE_ASSIGN_NAME_LEN 96
static int g_shift_zone_assign_active = 0;
static int g_shift_zone_assign_count = 0;
static int g_shift_zone_assign_station_list[MAX_STATION_ZONE_ASSIGNMENTS];
static uint8_t g_shift_zone_assign_selected[MAX_STATION_ZONE_ASSIGNMENTS];
static HWND g_zone_assign_hwnd = NULL;
static int g_zone_assign_station_i = -1;
static int g_station_zone_override_id[MAX_STATION_ZONE_ASSIGNMENTS];
static char g_station_zone_override_name[MAX_STATION_ZONE_ASSIGNMENTS][ZONE_ASSIGN_NAME_LEN];
static int g_station_zone_override_initialized = 0;
static int g_drag_painting = 0;
static int g_last_paint_grid_x = -1;
static int g_last_paint_grid_y = -1;
static int g_drag_affected_cells = 0;
static int g_drag_start_grid_x = -1;
static int g_drag_start_grid_y = -1;
static size_t g_drag_source_start_count = 0;
static float g_drag_start_delay_cursor_s = 0.0f;
static int g_brush_radius_cells = 2;       // legacy/mask brush radius in cells
static float g_fault_width_km = 20.0f;      // actual fault-plane width for new source stamps, in km
static int g_source_profile_mode = 1;       // 0 = flat brush, 1 = curved reverse-fault source
static int g_subsidence_side = 1;           // +1 = right-hand side of strike, -1 = left-hand side of strike
static float g_fault_strike_deg = 0.0f;     // auto from mouse movement, clockwise from map north: 0=N-S, 90=E-W
static float g_strike_smoothing_strength = 2.0f; // 0.0..5.0; mouse tremor correction for auto strike
static int g_strike_filter_ready = 0;
static float g_strike_filter_east = 0.0f;
static float g_strike_filter_north = 1.0f;
static float g_strike_accum_east = 0.0f;
static float g_strike_accum_north = 0.0f;
static float g_fault_dip_deg = 15.0f;        // realistic tsunami/subduction default; editable 5..60 degrees
static float g_source_depth_km = 20.0f;       // source depth for tsunami generation and Mw display
static float g_tsunami_depth_ref_km = 20.0f;  // shallower than this keeps full tsunami efficiency
static float g_tsunami_depth_decay_km = 45.0f;// deeper events lose tsunami efficiency with this scale
static float g_rigidity_pa = 3.0e10f;         // shear modulus for M0 = mu * area * slip
static float g_subsidence_ratio = 0.45f;    // depression amplitude relative to the uplift amount
static float g_strike_slip_mix = 0.0f;      // 0=pure reverse, 1=pure strike-slip-style weak surface disturbance
static int g_strike_slip_sense = 1;         // +1 right-lateral style, -1 left-lateral style for the toy shear lobe
static float g_rupture_velocity_km_s = 2.5f; // automatic source delay from mouse-path distance
static float g_rupture_delay_cursor_s = 0.0f;
static int g_bilateral_rupture = 0;
static int g_active_paint_delay_override_s = -1;
static const float g_map_top_lat_deg = 46.29f; // calibrated visual/top latitude from station grid mapping
static const float g_map_center_grid_x = 425.0f;
static const float g_map_center_grid_y = 270.0f;
static const float g_map_center_lat_deg = 35.91325f; // calibrated Mercator center latitude
static const float g_map_center_lon_deg = 135.79685f; // calibrated Mercator center longitude
static const float g_earth_radius_km = 6371.0088f;
static const float g_map_projected_km_per_cell_calibrated = 5.72096f; // Mercator-projected km/cell from station lat/lon vs grid fit
static char g_status[256] = "Ready. Use the panel buttons or open Settings.";
static int g_display_flip_x = DISPLAY_FLIP_X_DEFAULT;
static int g_display_flip_y = DISPLAY_FLIP_Y_DEFAULT;
static int g_coast_guard_cells = DEFAULT_COAST_GUARD_CELLS;
static int g_double_buffer_enabled = 1;
static int g_show_source_markers = 1;
static int g_show_coast_height_markers = 0;
static int g_show_time_series_markers = 0;  // hidden by default; press B to show selected station rings
static int g_coast_marker_use_peak = 1;
static int g_coast_marker_use_zone_max = 0;  // NHK-style: all stations in a forecast zone share the zone's max height
static int g_mask_edit_mode = 0;
static int g_mask_paint_tool = 0;     // 0 = force water, 1 = force land, -1 = clear correction
static int g_show_mask_overrides = 0;
static int g_show_bathymetry = 0;      // default: hide depth colors, show clear land/sea map
static int g_invert_landsea_display = 0; // default OFF: sea is blue by default; Ctrl+D then L can invert if needed
static float g_wave_color_max_m = 20.0f; // built-in table endpoint; Colors settings can define their own max.
static float g_wave_display_coeff = 1.0f; // 5/6 visual-only color coefficient; simulation height is unchanged
static float g_wave_overlay_alpha = 0.0f; // 0..1, now safe after DIB RGB/BGR fix
static int g_wave_layer_mode = WAVE_LAYER_LIVE; // live, max positive, or max+arrival contours
static int g_jesim_transmit_mode = 1;    // default ON: compute/send data, minimize expensive drawing
static int g_transmit_repaint_counter = 0;
static int g_transmit_repaint_interval_ticks = 90; // redraw about occasionally while running
static int g_base_cache_valid = 0;
static int g_base_cache_flip_x = -1;
static int g_base_cache_flip_y = -1;
static int g_base_cache_bathy = -1;
static int g_base_cache_invert = -1;
static int g_base_cache_overrides = -1;
static int g_debug_key_armed = 0;      // after Ctrl+D, next key toggles debug display options

#define MAX_COLOR_KEYFRAMES 32

typedef struct {
    float height_m;
    int r;
    int g;
    int b;
} ColorKeyframe;

static ColorKeyframe g_cell_color_keys[MAX_COLOR_KEYFRAMES];
static int g_cell_color_key_count = 0;
static int g_cell_color_custom_enabled = 0;
static int g_cell_color_smooth = 0;

static ColorKeyframe g_coast_color_keys[MAX_COLOR_KEYFRAMES];
static int g_coast_color_key_count = 0;
static int g_coast_color_custom_enabled = 0;
static int g_coast_color_smooth = 0;
static int g_coast_color_step = 0;



/* JESIM/Scratch bridge export state.
   The GUI writes newline-delimited JSON records to jesim_ws_outbox.jsonl.
   A tiny local WebSocket relay script included in this package forwards those
   records to Scratch/TurboWarp so the C simulation does not spend frame time on
   browser WebSocket handshakes. */
static char g_jesim_outbox_path_buf[MAX_PATH] = {0};

static const char *jesim_outbox_path(void) {
    if (g_jesim_outbox_path_buf[0] == '\0') {
        char exe_path[MAX_PATH];
        DWORD n = GetModuleFileNameA(NULL, exe_path, MAX_PATH);
        if (n > 0 && n < MAX_PATH) {
            char *slash1 = strrchr(exe_path, '\\');
            char *slash2 = strrchr(exe_path, '/');
            char *slash = slash1;
            if (slash2 && (!slash || slash2 > slash)) slash = slash2;
            if (slash) {
                *(slash + 1) = '\0';
                snprintf(g_jesim_outbox_path_buf, sizeof(g_jesim_outbox_path_buf),
                         "%sjesim_ws_outbox.jsonl", exe_path);
            } else {
                snprintf(g_jesim_outbox_path_buf, sizeof(g_jesim_outbox_path_buf),
                         "jesim_ws_outbox.jsonl");
            }
        } else {
            snprintf(g_jesim_outbox_path_buf, sizeof(g_jesim_outbox_path_buf),
                     "jesim_ws_outbox.jsonl");
        }
    }
    return g_jesim_outbox_path_buf;
}
static const char *g_jesim_observation_points_path = "jesim_observation_points.csv";
static float *g_jesim_prev_obs_m = NULL; /* legacy allocation kept for compatibility; observation trend now uses per-point arrays below. */
static int g_jesim_prev_obs_time_s = -1;
static int g_jesim_last_10min_bucket = -1;
static int g_jesim_sent_metadata = 0;
static int g_jesim_sent_test_snapshot = 0;
static int g_jesim_sent_6h = 0;
static int g_jesim_sent_12h = 0;
static int g_jesim_sent_24h = 0;
static int g_jesim_next_warning_check_h = 6;
static int g_jesim_last_sent_forecast_rank[94] = {0};
static int g_jesim_last_sent_observed_rank[94] = {0};
static int g_jesim_last_sent_forecast_active[94] = {0};
static int g_jesim_last_sent_observed_active[94] = {0};
static int g_jesim_last_sent_forecast_arrival_5min_s[94] = {0};
static int g_jesim_last_sent_observed_arrival_5min_s[94] = {0};
static int *g_jesim_wave_zone_map = NULL;
static size_t g_jesim_wave_zone_map_cells = 0;
static int g_jesim_realtime_active = 0;
static ULONGLONG g_jesim_realtime_start_ms = 0;
static LARGE_INTEGER g_jesim_realtime_qpc_freq = {0};
static LARGE_INTEGER g_jesim_realtime_start_qpc = {0};
static int g_jesim_realtime_qpc_valid = 0;
static int g_jesim_last_realtime_5min_bucket = -1;
static int g_jesim_export_enabled = 1;
/* Real-time bulletin synchronization.
   Observation_arrival dispatch is fixed to true wall-clock 1x.  The numerical
   simulation may run faster or slower internally, but reports are released
   only when an external monotonic clock says that the corresponding model
   second has arrived.  Warning_check remains driven by simulation/model time. */
static int g_jesim_calc_start_delay_s = 0;
#define JESIM_OBSERVATION_REALTIME_SPEED_FACTOR_FIXED 1
#define JESIM_TRANSMIT_ONLY_SIM_LIMIT_HOURS 60
#define JESIM_TRANSMIT_ONLY_SIM_LIMIT_S (JESIM_TRANSMIT_ONLY_SIM_LIMIT_HOURS * 3600)
/* Local JESIM gauge sampling uses a tighter radius than the legacy station
   sampler so the leading edge is not borrowed from the next coastal cell. */
#define JESIM_OBSERVATION_LOCAL_SAMPLE_RADIUS_CELLS 1

/* v15 station-table rebuild.
   Default marker coordinate = explicit station_no grid coordinate.
   When several different observation names intentionally share the same
   station_no, use the CSV lat/lon display coordinate to separate labels. */
#define JESIM_OBS_DISPLAY_FROM_LATLON_FOR_ALL 0
#define JESIM_OBS_DISPLAY_FROM_LATLON_FOR_DUPLICATE_STATION 1

#define JESIM_MAX_OBSERVATION_POINTS 512

typedef struct {
    char id[32];
    char name[96];
    char agency[48];
    float lat_deg;
    float lon_deg;
    int preferred_zone_id;  /* 0 = use nearest station; otherwise constrain mapping to this forecast zone first */
    int csv_station_no;     /* optional explicit 1-based simulator station number from jesim_observation_points.csv */
    int csv_station_no_duplicate; /* true when two or more observation rows intentionally share csv_station_no */
    int mapped_station_no;
    int mapped_grid_index;  /* preferred virtual/coastal sampling cell, or -1 if unavailable */
    int mapped_grid_x;
    int mapped_grid_y;
    int mapped_is_virtual_coast;
    float mapped_depth_m;
} JesimObservationPoint;

static JesimObservationPoint g_jesim_observation_points[JESIM_MAX_OBSERVATION_POINTS];
static int g_jesim_observation_point_count = 0;
static int g_jesim_observation_points_loaded = 0;
static int g_jesim_observation_csv_station_no_col = -1;
static int g_jesim_observation_csv_zone_id_col = -1;
/* Observation reports are queued by model time, then released by the
   synchronized real-time clock.  This is intentionally separate from
   warning_check, which is dispatched by simulation time only.

   The older single-shot observation_arrival arrays could send only one record
   per observation point.  That made the first 0.05 m arrival work, but later
   tide-gauge height increases were lost.  The queue below keeps both the first
   arrival and later 0.1 m display-step signed-level / peak updates.

   The solver may compute at 30-second steps, but JESIM observation bulletins
   should not be published every 30 seconds.  First arrivals and later value
   updates are both released on 300-second bulletin buckets.  The true
   first-arrival time is stored separately as observed_s and is not rounded.

   v31 rule: after an observation has arrived, every 5-minute bulletin includes
   that arrived point even when the displayed peak has not changed.  Per-record
   peak-update significance is not exported as a boolean because Scratch can use
   rise == "+" for each observation.  Only the packet-level meaningful flag is
   exported, and it is false only when no observation in the whole packet updates
   its displayed peak. */
#define JESIM_OBSERVATION_EVENT_QUEUE_CAP 16384
#define JESIM_OBSERVATION_HEIGHT_UPDATE_INTERVAL_S 300
#define JESIM_OBS_REPORT_FIRST_ARRIVAL 1
#define JESIM_OBS_REPORT_HEIGHT_UPDATE 2

typedef struct {
    int observation_index;
    int model_s;      /* scheduled bulletin/release time, used for wall-clock due check */
    int observed_s;   /* true observation time; for first arrival this is NOT rounded to 5 min */
    float live_m;
    float peak_m;
    int live_dm;
    int peak_dm;
    int rise_sign; /* JESIM broadcast rise marker: +1 only when displayed positive peak is updated, 0 otherwise */
    int meaningful_peak_update; /* true only when displayed positive peak newly increases */
    int peak_delta_dm;          /* displayed positive peak increase in 0.1 m units */
    int reason;

    /* Snapshot the observation identity and sampling target at queue time.
       Scratch/TurboWarp often appends observation records in arrival order, so
       do not let a later remap/reload or parallel cache lookup pair one
       observation's name with a neighbor's height. */
    char obs_id[32];
    char name[96];
    char agency[48];
    float lat_deg;
    float lon_deg;
    int station_no;
    int mapped_grid_index;
    int mapped_grid_x;
    int mapped_grid_y;
    int sample_grid_index;
    int sample_grid_x;
    int sample_grid_y;
    int mapped_is_virtual_coast;
    float mapped_depth_m;
    int preferred_zone_id;
    int forecast_zone_id;

    unsigned char sent;
} JesimObservationReportEvent;

static JesimObservationReportEvent g_jesim_observation_event_queue[JESIM_OBSERVATION_EVENT_QUEUE_CAP];
static int g_jesim_observation_event_count = 0;
static unsigned char g_jesim_observation_first_queued[JESIM_MAX_OBSERVATION_POINTS] = {0};
static int g_jesim_observation_last_queued_live_dm[JESIM_MAX_OBSERVATION_POINTS] = {0};
static int g_jesim_observation_last_queued_peak_dm[JESIM_MAX_OBSERVATION_POINTS] = {0};
static float g_jesim_observation_peak_m[JESIM_MAX_OBSERVATION_POINTS] = {0.0f};
static int g_jesim_observation_arrival_s[JESIM_MAX_OBSERVATION_POINTS] = {0};
static float g_jesim_prev_obs_point_m[JESIM_MAX_OBSERVATION_POINTS] = {0.0f};
static int g_jesim_observation_rise_sign_from_delta_dm(int delta_dm) {
    if (delta_dm > 0) return 1;
    if (delta_dm < 0) return -1;
    return 0;
}

static const char *g_jesim_observation_rise_symbol(int rise_sign) {
    if (rise_sign > 0) return "+";
    if (rise_sign < 0) return "-";
    return "0";
}

static const char *g_jesim_observation_rise_text(int rise_sign) {
    if (rise_sign > 0) return "rising";
    if (rise_sign < 0) return "falling";
    return "steady";
}

static int g_settings_current_tab = 0; /* 0 = Main, 1 = Colors */

static HWND g_main_hwnd = NULL;
static HWND g_settings_hwnd = NULL;
static HWND g_panel_buttons[9] = {0};

// Japanese forecast-zone names need a real CJK-capable font.
// The default Win32 GUI font can show some CJK characters as square boxes
// on non-Japanese Windows installations.
static HFONT g_text_font = NULL;
static wchar_t g_text_font_face[LF_FACESIZE] = L"";


typedef struct StationSeriesPoint {
    int time_s;
    float level_m;          // raw signed water-level change at the station sample cell
    float positive_rise_m;  // max(level_m, 0), matching observation/forecast logic
    float peak_positive_m;  // station peak-so-far after this step
} StationSeriesPoint;

typedef struct StationSeriesRecorder {
    int station_i;          // selected observation point, 0-based index
    StationSeriesPoint *points;
    size_t count;
    size_t capacity;
    int last_time_s;
} StationSeriesRecorder;

static int g_ts_station_i = -1;              // primary/last selected observation point, 0-based index
static StationSeriesRecorder *g_ts_recorders = NULL;
static size_t g_ts_rec_count = 0;
static size_t g_ts_rec_capacity = 0;

static int g_view_x = 0;
static int g_view_y = 0;
static int g_view_w = 1;
static int g_view_h = 1;
static double g_view_zoom = 1.0;   // Ctrl+wheel zoom, relative to fit-to-window
static double g_view_pan_x = 0.0;  // pixel pan offset inside the map area
static double g_view_pan_y = 0.0;
static int g_pan_dragging = 0;
static int g_pan_last_mouse_x = 0;
static int g_pan_last_mouse_y = 0;

static float clampf_gui(float x, float lo, float hi);
static int round_delay_to_step(int delay_s);
static void set_status(const char *fmt, ...);
static void settings_refresh(void);
static void settings_changed(const char *message);
static int auto_save_color_settings(void);
static int auto_load_color_settings(void);
static void jesim_export_tick(void);
static void jesim_realtime_update_tick(void);
static void jesim_start_realtime_warning_clock(void);
static void jesim_free_wave_zone_map(void);
static void jesim_rebuild_wave_zone_map(void);
static int jesim_wave_zone_for_cell(size_t idx);
static int display_cell_is_land(size_t grid_idx);
static int is_display_excluded_cell(size_t grid_idx);
static void jesim_export_metadata(const char *kind);
static void jesim_load_observation_points(void);
static void jesim_clear_outbox(const char *reason);
static void jesim_reset_export_state(void);
static void jesim_free_export_state(void);

static uint32_t rgb32(int r, int g, int b) {
    /*
       Pixel format for 32-bit BI_RGB DIB:
       memory bytes are B, G, R, 0.

       Since g_pixels is uint32_t on little-endian Windows,
       storing 0x00RRGGBB produces bytes BB GG RR 00.
       Earlier builds stored 0x00BBGGRR, which made colors appear
       channel-swapped: 0m sky-blue became yellow, negative blue became red.
    */
    if (r < 0) r = 0; if (r > 255) r = 255;
    if (g < 0) g = 0; if (g > 255) g = 255;
    if (b < 0) b = 0; if (b > 255) b = 255;
    return ((uint32_t)r << 16) | ((uint32_t)g << 8) | (uint32_t)b;
}

static COLORREF hsv_to_colorref(float h_deg, float s, float v) {
    while (h_deg < 0.0f) h_deg += 360.0f;
    while (h_deg >= 360.0f) h_deg -= 360.0f;
    s = clampf_gui(s, 0.0f, 1.0f);
    v = clampf_gui(v, 0.0f, 1.0f);

    float c = v * s;
    float hp = h_deg / 60.0f;
    float x = c * (1.0f - fabsf(fmodf(hp, 2.0f) - 1.0f));
    float m = v - c;
    float r1 = 0.0f, g1 = 0.0f, b1 = 0.0f;

    if (hp < 1.0f) { r1 = c; g1 = x; }
    else if (hp < 2.0f) { r1 = x; g1 = c; }
    else if (hp < 3.0f) { g1 = c; b1 = x; }
    else if (hp < 4.0f) { g1 = x; b1 = c; }
    else if (hp < 5.0f) { r1 = x; b1 = c; }
    else { r1 = c; b1 = x; }

    int r = (int)((r1 + m) * 255.0f + 0.5f);
    int g = (int)((g1 + m) * 255.0f + 0.5f);
    int b = (int)((b1 + m) * 255.0f + 0.5f);
    return RGB(r, g, b);
}

static uint32_t colorref_to_rgb32(COLORREF c) {
    return rgb32(GetRValue(c), GetGValue(c), GetBValue(c));
}

static float clampf_gui(float x, float lo, float hi) {
    if (x < lo) return lo;
    if (x > hi) return hi;
    return x;
}

static int clampi_gui(int x, int lo, int hi) {
    if (x < lo) return lo;
    if (x > hi) return hi;
    return x;
}

static const char *source_profile_name(void) {
    return g_source_profile_mode ? "curved reverse-fault" : "flat brush";
}

static float normalize_deg_gui(float a) {
    while (a < 0.0f) a += 360.0f;
    while (a >= 360.0f) a -= 360.0f;
    return a;
}

static const char *compass16_from_azimuth(float deg) {
    static const char *names[16] = {
        "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
        "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"
    };
    deg = normalize_deg_gui(deg);
    int idx = (int)floorf((deg + 11.25f) / 22.5f);
    idx &= 15;
    return names[idx];
}

static float subsidence_azimuth_deg(void) {
    /* Strike is a compass azimuth clockwise from north.
       Right-hand normal is strike+90 degrees; left-hand normal is strike-90. */
    return normalize_deg_gui(g_fault_strike_deg + (g_subsidence_side > 0 ? 90.0f : -90.0f));
}

static const char *subsidence_side_name(void) {
    static char buf[96];
    snprintf(buf, sizeof(buf), "%s of strike, toward %s", 
             g_subsidence_side > 0 ? "right" : "left",
             compass16_from_azimuth(subsidence_azimuth_deg()));
    return buf;
}

static const char *strike_slip_sense_name(void) {
    return g_strike_slip_sense > 0 ? "right-lateral" : "left-lateral";
}

static float dip_width_scale(void) {
    /*
       Shallow megathrusts spread deformation over a wider across-dip region.
       This is only a GUI source-shape approximation, not a full Okada solver.
    */
    float dip = clampf_gui(g_fault_dip_deg, 5.0f, 60.0f);
    float scale = 45.0f / dip;
    return clampf_gui(scale, 0.75f, 3.0f);
}

static float strike_slip_water_factor(void) {
    /*
       Pure strike-slip on a perfectly flat seabed has little direct vertical
       displacement.  This factor gives a small tsunami-style surface
       disturbance to represent horizontal forcing over real seabed slopes and
       complex fault geometry.  Keep it intentionally conservative.
    */
    return 0.18f;
}

static float deg_to_rad_gui(float deg) {
    return deg * TSUNAMI_PI / 180.0f;
}

static float rad_to_deg_gui(float rad) {
    return rad * 180.0f / TSUNAMI_PI;
}

static float mercator_y_from_lat_rad(float lat_rad) {
    return logf(tanf(TSUNAMI_PI * 0.25f + lat_rad * 0.5f));
}

static float lat_rad_from_mercator_y(float y) {
    return 2.0f * atanf(expf(y)) - TSUNAMI_PI * 0.5f;
}

static float map_top_grid_y(void) {
    /*
       The GUI displays the northern edge at the top.  In the solver/grid
       convention used here, y increases northward, so the visual top edge is
       the largest grid-y value.
    */
    if (g_grid.height > 1) return (float)g_grid.height - 1.0f;
    return g_map_center_grid_y + 1.0f;
}

static float map_projected_km_per_cell_from_top_lat(void) {
    /*
       v46: use station-calibrated Mercator scale instead of the old hand-picked
       top-latitude scale.  Fitting the uploaded station lat/lon list against
       g_grid.station_index gave:

         projected km/cell ~= 5.72096
         ground km/cell at 35.9N ~= 4.63

       The old g_map_top_lat_deg=42.25 path returned only about 3.39 projected
       km/cell, or 2.75 ground km/cell at the map center.  That made stamp
       plane-depth increments and source-node area roughly too small, which is
       why low-dip stamps looked only about half as deep and Mw was biased low.
    */
    return g_map_projected_km_per_cell_calibrated;
}

static float map_center_ground_km_per_cell(void) {
    float center_lat = deg_to_rad_gui(g_map_center_lat_deg);
    return map_projected_km_per_cell_from_top_lat() * cosf(center_lat);
}

static float grid_lat_deg_mercator(float gy) {
    /*
       Approximate inverse Mercator mapping around the calibrated map center:
         grid center (425,270) ~= lat 35.91325, lon 135.79685
         projected scale       ~= 5.72096 km/cell
    */
    float center_lat = deg_to_rad_gui(g_map_center_lat_deg);
    float center_y = mercator_y_from_lat_rad(center_lat);
    float projected_km_per_cell = map_projected_km_per_cell_from_top_lat();

    float dy_cells = gy - g_map_center_grid_y;
    float y = center_y + (dy_cells * projected_km_per_cell / g_earth_radius_km);
    return rad_to_deg_gui(lat_rad_from_mercator_y(y));
}

static float grid_x_from_lon_deg_mercator(float lon_deg) {
    float projected_km_per_cell = map_projected_km_per_cell_from_top_lat();
    float dx_km = g_earth_radius_km * deg_to_rad_gui(lon_deg - g_map_center_lon_deg);
    return g_map_center_grid_x + dx_km / projected_km_per_cell;
}

static float grid_y_from_lat_deg_mercator(float lat_deg) {
    if (lat_deg > 84.0f) lat_deg = 84.0f;
    if (lat_deg < -84.0f) lat_deg = -84.0f;
    float center_lat = deg_to_rad_gui(g_map_center_lat_deg);
    float center_y = mercator_y_from_lat_rad(center_lat);
    float y = mercator_y_from_lat_rad(deg_to_rad_gui(lat_deg));
    float projected_km_per_cell = map_projected_km_per_cell_from_top_lat();
    return g_map_center_grid_y + (y - center_y) * g_earth_radius_km / projected_km_per_cell;
}

static float local_ground_km_per_cell_mercator(float gy) {
    float lat = deg_to_rad_gui(grid_lat_deg_mercator(gy));
    float projected_km_per_cell = map_projected_km_per_cell_from_top_lat();
    float scale = projected_km_per_cell * cosf(lat);
    if (scale < 0.01f) scale = 0.01f;
    return scale;
}

#define FAULT_WIDTH_MIN_KM 1.0f
#define FAULT_WIDTH_MAX_KM 800.0f

static float normalized_fault_width_km(float width_km) {
    if (!isfinite(width_km)) width_km = 20.0f;
    return clampf_gui(width_km, FAULT_WIDTH_MIN_KM, FAULT_WIDTH_MAX_KM);
}

static float fault_dip_cos_deg(float dip_deg) {
    float dip = clampf_gui(dip_deg, 5.0f, 60.0f);
    float c = cosf(deg_to_rad_gui(dip));
    if (c < 0.25f) c = 0.25f;
    return c;
}

static float current_fault_width_km(void) {
    g_fault_width_km = normalized_fault_width_km(g_fault_width_km);
    return g_fault_width_km;
}

static float current_fault_surface_projected_width_km(void) {
    /*
       The UI width is the true down-dip fault-plane width.  A dipping plane
       occupies a shorter horizontal map projection by cos(dip).  M0/Mw later
       converts the counted horizontal cells back to fault-plane area by /cos(dip).
    */
    return current_fault_width_km() * fault_dip_cos_deg(g_fault_dip_deg);
}

static float fault_width_radius_cells_at_y(float gy) {
    float km_per_cell = local_ground_km_per_cell_mercator(gy);
    float projected_full_width_km = current_fault_surface_projected_width_km();
    float half_cells = 0.5f * projected_full_width_km / km_per_cell;
    return clampf_gui(half_cells, 2.0f, 120.0f);
}

static int fault_width_radius_cells_int_at_y(float gy) {
    return clampi_gui((int)lroundf(fault_width_radius_cells_at_y(gy)), 2, 120);
}

static float legacy_brush_radius_cells_to_fault_width_km(int radius_cells) {
    int r = clampi_gui(radius_cells, 2, 120);
    float km_per_cell = map_center_ground_km_per_cell();
    float c = fault_dip_cos_deg(g_fault_dip_deg);
    return normalized_fault_width_km((2.0f * (float)r * km_per_cell) / c);
}

static void set_fault_width_km_value(float width_km) {
    g_fault_width_km = normalized_fault_width_km(width_km);
    /* Keep a legacy cell-radius value for mask painting and old status paths. */
    g_brush_radius_cells = fault_width_radius_cells_int_at_y(g_map_center_grid_y);
}

static float fault_width_key_step_km(void) {
    if (GetKeyState(VK_CONTROL) & 0x8000) return 1.0f;
    if (GetKeyState(VK_SHIFT) & 0x8000) return 20.0f;
    return 5.0f;
}

static float grid_segment_distance_km(int x0, int y0, int x1, int y1) {
    float dx = (float)(x1 - x0);
    float dy = (float)(y1 - y0);
    float mid_y = ((float)y0 + (float)y1) * 0.5f;
    float km_per_cell = local_ground_km_per_cell_mercator(mid_y);
    return sqrtf(dx * dx + dy * dy) * km_per_cell;
}

static float strike_from_grid_motion_deg(int x0, int y0, int x1, int y1) {
    /*
       Grid coordinates are interpreted as x=east, y=north.
       Mercator is conformal, so the local bearing of a short mouse segment is
       atan2(east, north).  The latitude-dependent scale is used for distance,
       while the angle stays consistent under Mercator.
    */
    float east = (float)(x1 - x0);
    float north = (float)(y1 - y0);
    if (fabsf(east) < 0.001f && fabsf(north) < 0.001f) return g_fault_strike_deg;
    return normalize_deg_gui(rad_to_deg_gui(atan2f(east, north)));
}

static void reset_auto_strike_filter(void) {
    g_strike_filter_ready = 0;
    g_strike_filter_east = sinf(deg_to_rad_gui(g_fault_strike_deg));
    g_strike_filter_north = cosf(deg_to_rad_gui(g_fault_strike_deg));
    g_strike_accum_east = 0.0f;
    g_strike_accum_north = 0.0f;
}

static void update_auto_strike_from_motion(int x0, int y0, int x1, int y1) {
    if (x0 == x1 && y0 == y1) return;

    float strength = clampf_gui(g_strike_smoothing_strength, 0.0f, 5.0f);
    float east = (float)(x1 - x0);
    float north = (float)(y1 - y0);

    if (strength <= 0.01f) {
        g_fault_strike_deg = strike_from_grid_motion_deg(x0, y0, x1, y1);
        g_strike_filter_ready = 1;
        g_strike_filter_east = sinf(deg_to_rad_gui(g_fault_strike_deg));
        g_strike_filter_north = cosf(deg_to_rad_gui(g_fault_strike_deg));
        g_strike_accum_east = 0.0f;
        g_strike_accum_north = 0.0f;
        return;
    }

    /*
       Mouse tremor correction:
       1) Accumulate very short movements before they are allowed to affect strike.
       2) Low-pass filter the unit direction vector instead of filtering degrees,
          so 359->0 degree wrap does not jump.
    */
    g_strike_accum_east += east;
    g_strike_accum_north += north;

    float accum_len = sqrtf(g_strike_accum_east * g_strike_accum_east +
                            g_strike_accum_north * g_strike_accum_north);

    float min_len_cells = 0.75f + strength * 1.25f;
    if (accum_len < min_len_cells) return;

    float raw_e = g_strike_accum_east / accum_len;
    float raw_n = g_strike_accum_north / accum_len;
    g_strike_accum_east = 0.0f;
    g_strike_accum_north = 0.0f;

    if (!g_strike_filter_ready) {
        g_strike_filter_ready = 1;
        g_strike_filter_east = raw_e;
        g_strike_filter_north = raw_n;
    } else {
        float alpha = 1.0f / (1.0f + strength * 2.4f);
        g_strike_filter_east = g_strike_filter_east * (1.0f - alpha) + raw_e * alpha;
        g_strike_filter_north = g_strike_filter_north * (1.0f - alpha) + raw_n * alpha;

        float len = sqrtf(g_strike_filter_east * g_strike_filter_east +
                          g_strike_filter_north * g_strike_filter_north);
        if (len > 0.0001f) {
            g_strike_filter_east /= len;
            g_strike_filter_north /= len;
        } else {
            g_strike_filter_east = raw_e;
            g_strike_filter_north = raw_n;
        }
    }

    g_fault_strike_deg = normalize_deg_gui(rad_to_deg_gui(atan2f(g_strike_filter_east,
                                                                 g_strike_filter_north)));
}

static int rounded_source_delay_with_cursor(float cursor_s) {
    /*
       Keep per-cell rupture delays at roughly 1-second precision.
       Do not round every source to the simulation dt, because long ruptures
       then create large same-delay bands that look like bamboo nodes.
       The core solver now ramps each source cell in smoothly over time.
    */
    int delay = g_new_source_delay_s + (int)lroundf(cursor_s);
    if (delay < 0) delay = 0;
    if (g_time_s >= delay) delay = g_time_s + 1;
    return delay;
}

static void reset_rupture_delay_cursor(void) {
    g_rupture_delay_cursor_s = 0.0f;
    g_active_paint_delay_override_s = -1;
    set_status("Rupture delay cursor reset to 0 s.");
}

static void clear_sources_and_reset_rupture_delay(void) {
    source_list_clear(&g_sources);
    g_selected_source_i = -1;
    g_rupture_delay_cursor_s = 0.0f;
    g_drag_start_delay_cursor_s = 0.0f;
    g_active_paint_delay_override_s = -1;
    g_stamp_fault_count = 0;
    g_current_stamp_fault = -1;
    g_next_stamp_patch_id = 1;
}

static void update_recent_source_delays_bilateral(size_t start_index,
                                                  int start_x, int start_y,
                                                  int end_x, int end_y,
                                                  float start_cursor_s) {
    if (!g_bilateral_rupture) return;
    if (g_rupture_velocity_km_s <= 0.01f) return;
    if (start_index >= g_sources.count) return;

    for (size_t i = start_index; i < g_sources.count; ++i) {
        int gx = g_sources.items[i].grid_x;
        int gy = g_sources.items[i].grid_y;
        float d1 = grid_segment_distance_km(start_x, start_y, gx, gy);
        float d2 = grid_segment_distance_km(end_x, end_y, gx, gy);
        float extra_s = fminf(d1, d2) / g_rupture_velocity_km_s;
        int delay = rounded_source_delay_with_cursor(start_cursor_s + extra_s);

        g_sources.items[i].delay_s = delay;
        size_t idx = tsunami_index(&g_grid, gx, gy);
        g_sim.source_delay_s[idx] = delay;
    }
}

static float current_source_input_slip_m(void) {
    return g_new_source_height_m;
}

static float current_source_metric_slip_m(void) {
    float slip = current_source_input_slip_m();
    if (slip < 0.0f) slip = -slip;
    if (slip < 0.0f) slip = 0.0f;
    return slip;
}

#define SOURCE_SLIP_HEIGHT_EPS_M 0.0005f

static int source_height_is_subsidence(float height_m) {
    return height_m < -SOURCE_SLIP_HEIGHT_EPS_M;
}

static int source_node_counts_for_moment_area(const SourceSpec *src) {
    /*
       Seismic M0 uses the fault/rupture footprint, not every visible sea-surface
       disturbance.  The negative water-height lobe around a reverse-fault source
       is a surrounding subsidence response, so it must not enlarge fault-plane
       area or carry slip.

       Count only direct rupture-footprint nodes:
         - positive/uplift nodes, including old presets with height>0 and slip=0
         - footprint-only nodes with height==0 and slip_m>0

       Never count height<0 subsidence nodes for M0 area.
    */
    if (!src) return 0;
    /* v48: automatically generated secondary deformation nodes use
       fault_patch_id < 0 and slip_m = 0.  They may be negative subsidence or
       weak positive elastic-uplift shoulders, but neither represents extra
       fault-plane area. */
    if (src->fault_id > 0 && src->fault_patch_id < 0) return 0;
    if (source_height_is_subsidence(src->height_m)) return 0;

    float slip_m = src->slip_m;
    if (slip_m < 0.0f) slip_m = -slip_m;
    if (slip_m > SOURCE_SLIP_HEIGHT_EPS_M) return 1;
    if (src->height_m > SOURCE_SLIP_HEIGHT_EPS_M) return 1;
    return 0;
}

static float source_node_moment_slip_m(const SourceSpec *src) {
    if (!source_node_counts_for_moment_area(src)) return 0.0f;

    float slip_m = src->slip_m;
    if (slip_m < 0.0f) slip_m = -slip_m;

    if (slip_m <= SOURCE_SLIP_HEIGHT_EPS_M && src->height_m > SOURCE_SLIP_HEIGHT_EPS_M) {
        /* Backward compatibility for old uplift-only presets/source CSVs. */
        slip_m = src->height_m;
    }

    if (slip_m < 0.0f) slip_m = 0.0f;
    return slip_m;
}

static float sanitize_source_slip_for_height(float height_m, float slip_m) {
    /*
       slip_m is the fault/moment slip carried by an uplift/rupture footprint.
       Subsidence-only water nodes are just the negative lobe of the sea-surface
       deformation field; they must not carry their own slip, otherwise the
       source moment and advanced-node UI double-count the depression side.

       height == 0 is still allowed to carry slip for footprint-only land/coast
       nodes, so inland rupture area can remain part of M0 without injecting
       water height into the solver.
    */
    if (source_height_is_subsidence(height_m)) return 0.0f;
    if (slip_m < 0.0f) slip_m = -slip_m;
    if (slip_m < SOURCE_SLIP_HEIGHT_EPS_M) return 0.0f;
    return slip_m;
}

static void sanitize_source_node_slip(SourceSpec *src) {
    if (!src) return;
    src->slip_m = sanitize_source_slip_for_height(src->height_m, src->slip_m);
}

static void sanitize_all_source_node_slips(void) {
    for (size_t i = 0; i < g_sources.count; ++i) {
        sanitize_source_node_slip(&g_sources.items[i]);
    }
}

static float source_cell_fallback_slip_m(float height_m) {
    if (source_height_is_subsidence(height_m)) return 0.0f;

    float slip = current_source_metric_slip_m();
    if (slip > 0.0f) return slip;

    if (height_m > SOURCE_SLIP_HEIGHT_EPS_M) return height_m;
    return 0.0f;
}

static float blend_source_slip_metric_m(float old_slip, float new_slip, int exact_replace) {
    if (new_slip < 0.0f) new_slip = -new_slip;
    if (old_slip < 0.0f) old_slip = -old_slip;

    if (exact_replace) return new_slip;
    if (old_slip <= 0.0f) return new_slip;
    if (new_slip <= 0.0f) return old_slip;

    /*
       Smoothly move the cell's slip metric toward the newly painted slip.
       This keeps mixed-slip strokes from looking like one hard overwrite while
       still preventing repeated brush passes from endlessly stacking moment.
    */
    float blend = (new_slip > old_slip) ? 0.65f : 0.25f;
    return old_slip + (new_slip - old_slip) * blend;
}

static float blend_source_height_m(float old_h, float new_h, int exact_replace) {
    if (exact_replace || !g_source_profile_mode) return new_h;
    if (fabsf(old_h) < 0.0005f) return new_h;
    if (fabsf(new_h) < 0.0005f) return old_h;

    /*
       Source water height must remain a stable distributed deformation field.
       Do NOT average it down toward later weaker brush samples; that can make
       only the final mouse position look uplifted.
       Keep the stronger same-sign value and only let stronger opposite-sign
       lobes replace near uplift/subsidence boundaries.
    */
    if ((old_h >= 0.0f && new_h >= 0.0f) || (old_h < 0.0f && new_h < 0.0f)) {
        return fabsf(new_h) > fabsf(old_h) ? new_h : old_h;
    }

    if (fabsf(new_h) > fabsf(old_h) * 1.08f) return new_h;
    return old_h;
}

static int combine_source_delay_s(int old_delay, int new_delay) {
    if (old_delay <= 0) return new_delay;
    if (new_delay <= 0) return old_delay;
    return new_delay < old_delay ? new_delay : old_delay;
}

static float current_moment_projection_area_correction(void);
static void source_apply_current_fault_metadata(SourceSpec *src);
static void source_set_last_fault_metadata(SourceList *list);
static float source_node_moment_area_factor(const SourceSpec *src);
static float source_node_stamp_dip_deg(const SourceSpec *src);

static void source_set_last_slip_m(SourceList *list, float slip_m) {
    if (!list || list->count == 0) return;
    SourceSpec *src = &list->items[list->count - 1];
    src->slip_m = sanitize_source_slip_for_height(src->height_m, slip_m);
    source_apply_current_fault_metadata(src);
}


static float thrust_tsunami_depth_factor_at_km(float depth_km) {
    /*
       Depth attenuation for thrust/reverse tsunami generation.

       Tuned to avoid over-damping moderately deep reverse faults:
         depth <= 20 km : full efficiency
         20-80 km       : S-shaped decay, with the strongest drop near 60 km
         depth = 80 km  : 0.15x
         depth = 150 km : about 0.005x, practically negligible

       The 20-80 km section is a normalized logistic curve.  This keeps the
       shallow-depth drop gentle, then makes the attenuation steepest around
       60 km, instead of removing too much uplift too early.
    */
    float d = clampf_gui(depth_km, 0.0f, 700.0f);
    if (d <= 20.0f) return 1.0f;

    if (d <= 80.0f) {
        const float center = 60.0f;
        const float width = 10.0f;
        const float u20 = 1.0f / (1.0f + expf(-(20.0f - center) / width));
        const float u80 = 1.0f / (1.0f + expf(-(80.0f - center) / width));
        float u = 1.0f / (1.0f + expf(-(d - center) / width));
        float t = (u - u20) / (u80 - u20);
        float f = 1.0f - 0.85f * t;
        return clampf_gui(f, 0.15f, 1.0f);
    }

    {
        const float k = 3.401197382f / 70.0f; /* ln(30)/(150-80): 0.15 -> 0.005 */
        float f = 0.15f * expf(-k * (d - 80.0f));
        return clampf_gui(f, 0.0f, 0.15f);
    }
}

static float strike_slip_tsunami_depth_factor_at_km(float depth_km) {
    /*
       Strike-slip tsunami generation is shallower-sensitive.
       Calibrated so it is about 0.05x by 50 km, then asymptotically approaches 0.
    */
    float d = clampf_gui(depth_km, 0.0f, 700.0f);
    if (d <= 10.0f) return 1.0f;

    const float k = 2.995732274f / 40.0f; /* ln(20)/(50-10) */
    float f = expf(-k * (d - 10.0f));
    return clampf_gui(f, 0.0f, 1.0f);
}

static float current_tsunami_depth_factor(void) {
    return thrust_tsunami_depth_factor_at_km(g_source_depth_km);
}

static float current_strike_slip_depth_factor(void) {
    return strike_slip_tsunami_depth_factor_at_km(g_source_depth_km);
}

static float fault_plane_cell_depth_km(int center_x, int center_y,
                                       int x, int y,
                                       float down_dip_x, float down_dip_y,
                                       float half_width_cells) {
    /*
       The UI depth input is now interpreted as the shallowest/top edge depth.
       For a dipping source, each painted source cell gets its own depth:
         local_depth = shallowest_depth + distance_down_dip * sin(dip)

       down_dip_x/y is the horizontal down-dip direction in grid coordinates.
    */
    float shallow_km = clampf_gui(g_source_depth_km, 0.0f, 700.0f);
    float rel_cells =
        ((float)x - (float)center_x) * down_dip_x +
        ((float)y - (float)center_y) * down_dip_y +
        half_width_cells;

    if (rel_cells < 0.0f) rel_cells = 0.0f;

    float km_per_cell = local_ground_km_per_cell_mercator((float)y);
    float dip_rad = clampf_gui(g_fault_dip_deg, 5.0f, 60.0f) * TSUNAMI_PI / 180.0f;
    float c = cosf(dip_rad);
    if (c < 0.25f) c = 0.25f;
    /* rel_cells is horizontal down-dip projection; convert to vertical depth by tan(dip). */
    float extra_km = rel_cells * km_per_cell * (sinf(dip_rad) / c);
    return clampf_gui(shallow_km + extra_km, 0.0f, 700.0f);
}

static float depth_at_grid_safe(int x, int y) {
    if (x < 0 || y < 0 || x >= (int)g_grid.width || y >= (int)g_grid.height) return 0.0f;
    size_t idx = tsunami_index(&g_grid, (uint32_t)x, (uint32_t)y);
    if (g_grid.land[idx]) return 0.0f;
    return clampf_gui(g_grid.depth_m[idx], 0.0f, 11000.0f);
}

static float local_bathymetry_slope_m_per_m(int x, int y, float dir_x, float dir_y) {
    /*
       Central-difference bathymetry slope along an arbitrary horizontal
       direction.  This is a simple Tanioka-Satake-style proxy: horizontal
       displacement over a slope can push the water column vertically.
    */
    if (x <= 0 || y <= 0 || x >= (int)g_grid.width - 1 || y >= (int)g_grid.height - 1) return 0.0f;

    float km_cell = local_ground_km_per_cell_mercator((float)y);
    if (km_cell < 0.01f) km_cell = 0.01f;
    float denom_m = 2.0f * km_cell * 1000.0f;

    float dzdx = (depth_at_grid_safe(x + 1, y) - depth_at_grid_safe(x - 1, y)) / denom_m;
    float dzdy = (depth_at_grid_safe(x, y + 1) - depth_at_grid_safe(x, y - 1)) / denom_m;

    return fabsf(dzdx * dir_x + dzdy * dir_y);
}

static float strike_slip_slope_push_factor(int x, int y,
                                           float ss_strike_x, float ss_strike_y,
                                           float ss_normal_x, float ss_normal_y) {
    /*
       Horizontal strike-slip motion matters most when it pushes sloping
       bathymetry or complicated fault-tip/releasing/restraining-bend geometry.
       With no detailed Okada+bathymetry solver, use local depth-gradient as a
       conservative multiplier.  Flat seafloor still retains a small value for
       fault-tip/off-fault vertical components, but remains weak.
    */
    float slope_along = local_bathymetry_slope_m_per_m(x, y, ss_strike_x, ss_strike_y);
    float slope_across = local_bathymetry_slope_m_per_m(x, y, ss_normal_x, ss_normal_y);
    float slope = fmaxf(slope_along, 0.6f * slope_across);

    return clampf_gui(0.35f + 7.0f * slope, 0.25f, 1.50f);
}


static float current_reverse_peak_uplift_raw_m(void) {
    float slip = current_source_input_slip_m();
    float dip_rad = clampf_gui(g_fault_dip_deg, 5.0f, 60.0f) * TSUNAMI_PI / 180.0f;
    float reverse_mix = clampf_gui(1.0f - g_strike_slip_mix, 0.0f, 1.0f);
    /*
       User input is slip.  Approximate the vertical component of the dip-slip
       part, then the caller applies depth attenuation.
    */
    return slip * sinf(dip_rad) * reverse_mix;
}

static float current_reverse_peak_uplift_m(void) {
    return current_reverse_peak_uplift_raw_m() * current_tsunami_depth_factor();
}

static float current_strike_slip_peak_disturbance_raw_m(void) {
    float slip = current_source_input_slip_m();
    float strike_mix = clampf_gui(g_strike_slip_mix, 0.0f, 1.0f);
    return slip * strike_mix * strike_slip_water_factor();
}

static float current_strike_slip_peak_disturbance_m(void) {
    return current_strike_slip_peak_disturbance_raw_m() * current_strike_slip_depth_factor();
}

static float current_equivalent_peak_surface_m(void) {
    return current_reverse_peak_uplift_m() + current_strike_slip_peak_disturbance_m();
}

static float current_source_horizontal_area_km2(void) {
    /*
       Fault-area estimate for Mw/M0.  Count only direct rupture-footprint nodes
       and exclude the surrounding subsidence lobe.  This keeps the displayed
       sea-surface deformation from artificially enlarging seismic moment.
    */
    double area = 0.0;

    if (g_sources.count > 0) {
        for (size_t i = 0; i < g_sources.count; ++i) {
            const SourceSpec *src = &g_sources.items[i];
            if (!source_node_counts_for_moment_area(src)) continue;

            float km = local_ground_km_per_cell_mercator((float)src->grid_y);
            area += (double)km * (double)km;
        }

        if (area <= 0.0) return 0.0f;
    }

    if (area <= 0.0) {
        float r = fault_width_radius_cells_at_y(g_map_center_grid_y);
        float km = map_center_ground_km_per_cell();
        /* Before drawing, estimate the next direct rupture footprint from the km fault width. */
        area = 3.14159265358979323846 * (double)r * (double)r * (double)km * (double)km;
    }

    if (area < 0.001) area = 0.001;
    return (float)area;
}

static float current_moment_projection_area_correction(void) {
    /*
       v38: fault width is entered as the true fault-plane width in km.
       New source stamps draw the horizontal map projection of that width:
           projected_width = fault_width_km * cos(dip)
       Therefore each counted horizontal cell must be converted back to fault-
       plane area by dividing by cos(dip).  This keeps identical length / true
       width / slip sources nearly dip-invariant while still letting the map
       footprint visibly narrow at steep dip.
    */
    if (!g_source_profile_mode) return 1.0f;
    return 1.0f / fault_dip_cos_deg(g_fault_dip_deg);
}

static float source_stamp_dip_now_deg(void) {
    return clampf_gui(g_fault_dip_deg, 5.0f, 60.0f);
}

static void source_apply_current_fault_metadata(SourceSpec *src) {
    if (!src) return;
    src->stamp_dip_deg = source_stamp_dip_now_deg();
    src->moment_area_factor = current_moment_projection_area_correction();
    if (src->moment_area_factor < 0.01f || !isfinite(src->moment_area_factor)) {
        src->moment_area_factor = 1.0f;
    }
    if (g_active_stamp_fault_id >= 0) {
        src->fault_id = g_active_stamp_fault_id;
        src->fault_patch_id = g_active_stamp_patch_id;
        src->stamp_strike_deg = g_fault_strike_deg;
        src->stamp_depth_km = g_source_depth_km;
        src->stamp_width_km = current_fault_width_km();
        src->stamp_length_km = g_fault_stamp_length_km;
    }
}

static void source_set_last_fault_metadata(SourceList *list) {
    if (!list || list->count == 0) return;
    source_apply_current_fault_metadata(&list->items[list->count - 1]);
}

static float source_node_moment_area_factor(const SourceSpec *src) {
    if (!src) return current_moment_projection_area_correction();
    if (src->moment_area_factor > 0.01f && src->moment_area_factor < 100.0f && isfinite(src->moment_area_factor)) {
        return src->moment_area_factor;
    }
    return 1.0f / fault_dip_cos_deg(source_node_stamp_dip_deg(src));
}

static float source_node_stamp_dip_deg(const SourceSpec *src) {
    if (!src) return source_stamp_dip_now_deg();
    if (src->stamp_dip_deg >= 5.0f && src->stamp_dip_deg <= 60.0f && isfinite(src->stamp_dip_deg)) {
        return src->stamp_dip_deg;
    }
    return source_stamp_dip_now_deg();
}

static float current_source_moment_horizontal_area_km2(void) {
    if (g_sources.count > 0) {
        double area = 0.0;
        int counted = 0;
        for (size_t i = 0; i < g_sources.count; ++i) {
            const SourceSpec *src = &g_sources.items[i];
            if (!source_node_counts_for_moment_area(src)) continue;
            counted++;
            float km = local_ground_km_per_cell_mercator((float)src->grid_y);
            area += (double)km * (double)km * (double)source_node_moment_area_factor(src);
        }
        if (!counted || area <= 0.0) return 0.0f;
        if (area < 0.001) area = 0.001;
        return (float)area;
    }
    return current_source_horizontal_area_km2() * current_moment_projection_area_correction();
}

static float current_source_fault_area_km2(void) {
    /*
       This is already the calibrated fault-plane area estimate.  For v38 the
       GUI width is the true fault width in km; each source node freezes its own
       /cos(dip) projection factor when painted or loaded.
    */
    return current_source_moment_horizontal_area_km2();
}

static double current_seismic_moment_nm(void) {
    double mu = (double)g_rigidity_pa;
    if (mu < 1.0e9) mu = 1.0e9;

    double m0 = 0.0;

    if (g_sources.count > 0) {
        for (size_t i = 0; i < g_sources.count; ++i) {
            const SourceSpec *src = &g_sources.items[i];
            if (!source_node_counts_for_moment_area(src)) continue;

            double slip_m = (double)source_node_moment_slip_m(src);
            if (slip_m <= 0.0) continue;

            float km = local_ground_km_per_cell_mercator((float)src->grid_y);
            double fault_plane_area_m2 = (double)km * (double)km *
                                         (double)source_node_moment_area_factor(src) * 1000000.0;
            if (fault_plane_area_m2 < 1.0) fault_plane_area_m2 = 1.0;

            m0 += mu * fault_plane_area_m2 * slip_m;
        }
    }

    if (m0 <= 0.0 && g_sources.count == 0) {
        double slip_m = (double)current_source_metric_slip_m();
        double area_m2 = (double)current_source_fault_area_km2() * 1000000.0;
        if (slip_m < 0.0) slip_m = 0.0;
        if (area_m2 < 1.0) area_m2 = 1.0;
        m0 = mu * area_m2 * slip_m;
    }

    return m0;
}

static float current_moment_magnitude(void) {
    double m0 = current_seismic_moment_nm();
    if (m0 <= 0.0) return 0.0f;
    return (float)((2.0 / 3.0) * (log10(m0) - 9.10));
}

static void reverse_fault_basis_for(float strike_deg, int subsidence_side,
                                    float *strike_x, float *strike_y,
                                    float *normal_x, float *normal_y) {
    /* Grid coordinates are treated as x=east, y=north.  With the default GUI
       Y-flip, increasing grid-y appears upward on the map. */
    float rad = normalize_deg_gui(strike_deg) * (3.14159265358979323846f / 180.0f);
    float sx = sinf(rad);
    float sy = cosf(rad);
    float nx = cosf(rad);      // right-hand normal: east for a north-south strike
    float ny = -sinf(rad);
    if (subsidence_side < 0) {
        nx = -nx;
        ny = -ny;
    }
    if (strike_x) *strike_x = sx;
    if (strike_y) *strike_y = sy;
    if (normal_x) *normal_x = nx;
    if (normal_y) *normal_y = ny;
}

static void reverse_fault_basis(float *strike_x, float *strike_y, float *normal_x, float *normal_y) {
    reverse_fault_basis_for(g_fault_strike_deg, g_subsidence_side,
                            strike_x, strike_y, normal_x, normal_y);
}

static void strike_slip_fault_basis(float *ss_strike_x, float *ss_strike_y,
                                    float *ss_normal_x, float *ss_normal_y) {
    /*
       Strike-slip display correction:
       The reverse-fault stamp uses strike/normal axes for uplift and subsidence.
       The toy strike-slip disturbance was visually 90 degrees off in the GUI.

       For the strike-slip lobe only, rotate the active basis by 90 degrees so
       the drawn long axis matches the strike-slip fault trace seen on the map.
       This does not change the reverse-fault uplift/subsidence geometry.
    */
    float sx, sy, nx, ny;
    reverse_fault_basis(&sx, &sy, &nx, &ny);

    if (ss_strike_x) *ss_strike_x = nx;
    if (ss_strike_y) *ss_strike_y = ny;
    if (ss_normal_x) *ss_normal_x = -sx;
    if (ss_normal_y) *ss_normal_y = -sy;
}

static float strike_rotation_key_step_deg(void) {
    if (GetKeyState(VK_CONTROL) & 0x8000) return 1.0f;
    if (GetKeyState(VK_SHIFT) & 0x8000) return 15.0f;
    return 5.0f;
}

static int reverse_profile_radius_cells_at_y(float gy) {
    /*
       The profile scale comes from the real fault-plane width in km.  Convert
       to the surface-projected half-width in grid cells at the local latitude.
    */
    return fault_width_radius_cells_int_at_y(gy);
}

static int reverse_profile_radius_cells(void) {
    return reverse_profile_radius_cells_at_y(g_map_center_grid_y);
}

static float raised_cosine_weight(float normalized_radius) {
    if (normalized_radius >= 1.0f) return 0.0f;
    if (normalized_radius <= 0.0f) return 1.0f;
    return 0.5f * (1.0f + cosf(3.14159265358979323846f * normalized_radius));
}

static float rounded_superellipse_weight(float along_norm, float across_norm, float power) {
    /* A rounded rectangle / fat-ellipse mask.
       power=2 is an ellipse, larger values keep a straighter long middle while
       removing the square corners produced by independent along/across weights. */
    float a = fabsf(along_norm);
    float b = fabsf(across_norm);
    if (a >= 1.0f || b >= 1.0f) {
        /* The superellipse boundary can still include points with one axis at
           exactly 1 only at zero on the other axis, where weight is zero. */
        if (a > 1.0f || b > 1.0f) return 0.0f;
    }
    if (power < 1.2f) power = 1.2f;
    float r = powf(powf(a, power) + powf(b, power), 1.0f / power);
    return raised_cosine_weight(r);
}

static void set_status(const char *fmt, ...) {
    va_list ap;
    va_start(ap, fmt);
    vsnprintf(g_status, sizeof(g_status), fmt, ap);
    va_end(ap);
}

static void time_series_clear_points(void);
static int time_series_record_current(void);
static void time_series_free(void);
static void save_time_series_dialog(HWND hwnd);
static void save_station_csv_dialog(HWND hwnd);
static void make_suffixed_path(const char *path, const char *suffix, char *out, size_t out_size);

static const char *mask_tool_name(void) {
    if (g_mask_paint_tool > 0) return "force land";
    if (g_mask_paint_tool == 0) return "force water";
    return "clear correction";
}

static int jesim_transmit_only_step_allowed(void) {
    if (!g_jesim_transmit_mode) return 1;
    if (g_time_s >= JESIM_TRANSMIT_ONLY_SIM_LIMIT_S) return 0;
    if (g_dt_s > 0 && g_time_s + g_dt_s > JESIM_TRANSMIT_ONLY_SIM_LIMIT_S) return 0;
    return 1;
}

static void jesim_stop_at_transmit_only_limit(void) {
    if (!g_jesim_transmit_mode) return;
    if (g_time_s < JESIM_TRANSMIT_ONLY_SIM_LIMIT_S) return;
    if (g_time_s > JESIM_TRANSMIT_ONLY_SIM_LIMIT_S) g_time_s = JESIM_TRANSMIT_ONLY_SIM_LIMIT_S;
    if (g_running) g_running = 0;
    set_status("JESIM transmit-only simulation limit reached: %dh. Model stopped; pending observation reports keep using wall-clock 1x.",
               JESIM_TRANSMIT_ONLY_SIM_LIMIT_HOURS);
}

static void advance_one_step(void) {
    if (!jesim_transmit_only_step_allowed()) {
        if (g_jesim_transmit_mode && g_time_s < JESIM_TRANSMIT_ONLY_SIM_LIMIT_S) {
            g_time_s = JESIM_TRANSMIT_ONLY_SIM_LIMIT_S;
        }
        jesim_stop_at_transmit_only_limit();
        return;
    }

    /* Start the JESIM real-time clock at the first numerical-model step, not
       only when the Run button/Space key is used.  Otherwise observation reports
       generated during single-step/manual calculation can remain permanently
       gated by the real-time scheduler. */
    if (!g_jesim_realtime_active || g_jesim_realtime_start_ms == 0) {
        jesim_start_realtime_warning_clock();
    }

    g_time_s += g_dt_s;
    simulate_one_step(&g_grid, &g_sim, g_time_s);
    update_station_stats(&g_grid, &g_sim, g_time_s);
    time_series_record_current();
    jesim_export_tick();
    jesim_stop_at_transmit_only_limit();
}

static void reset_state_preserve_sources(void) {
    sanitize_all_source_node_slips();
    reset_simulation(&g_grid, &g_sim);
    apply_sources_to_simulation(&g_grid, &g_sim, &g_sources);
    g_time_s = 0;
    jesim_reset_export_state();
    time_series_clear_points();
    time_series_record_current();
    set_status("Reset. %zu source point(s) preserved.", g_sources.count);
}

static int source_index_is_valid(int source_i);
static void node_tune_clear_selection(void);
static void node_tune_select_only(int source_i);

static void source_undo_free_snapshot(SourceUndoSnapshot *snap) {
    if (!snap) return;
    free(snap->items);
    memset(snap, 0, sizeof(*snap));
}

static int source_undo_copy_current_into(SourceUndoSnapshot *snap, const char *label) {
    if (!snap) return 0;
    memset(snap, 0, sizeof(*snap));
    if (g_sources.count > 0) {
        snap->items = (SourceSpec *)malloc(g_sources.count * sizeof(SourceSpec));
        if (!snap->items) return 0;
        memcpy(snap->items, g_sources.items, g_sources.count * sizeof(SourceSpec));
    }
    snap->count = g_sources.count;
    memcpy(snap->stamp_faults, g_stamp_faults, sizeof(g_stamp_faults));
    snap->stamp_fault_count = g_stamp_fault_count;
    snap->current_stamp_fault = g_current_stamp_fault;
    snap->next_stamp_patch_id = g_next_stamp_patch_id;
    snap->selected_source_i = g_selected_source_i;
    snap->rupture_delay_cursor_s = g_rupture_delay_cursor_s;
    snap->drag_start_delay_cursor_s = g_drag_start_delay_cursor_s;
    snap->active_paint_delay_override_s = g_active_paint_delay_override_s;
    snprintf(snap->label, sizeof(snap->label), "%s", label ? label : "source edit");
    return 1;
}

static void source_undo_push(const char *label) {
    SourceUndoSnapshot snap;
    if (!source_undo_copy_current_into(&snap, label)) {
        MessageBeep(MB_ICONWARNING);
        set_status("Undo snapshot failed: out of memory. Edit continues without undo point.");
        return;
    }
    if (g_source_undo_count >= SOURCE_UNDO_DEPTH) {
        source_undo_free_snapshot(&g_source_undo_stack[0]);
        memmove(&g_source_undo_stack[0], &g_source_undo_stack[1],
                (SOURCE_UNDO_DEPTH - 1) * sizeof(SourceUndoSnapshot));
        g_source_undo_count = SOURCE_UNDO_DEPTH - 1;
    }
    g_source_undo_stack[g_source_undo_count++] = snap;
}

static int source_undo_restore(HWND hwnd) {
    if (g_source_undo_count <= 0) {
        MessageBeep(MB_ICONWARNING);
        set_status("Undo: no source edit to restore.");
        return 0;
    }

    SourceUndoSnapshot snap = g_source_undo_stack[--g_source_undo_count];
    if (snap.count > g_sources.capacity) {
        SourceSpec *new_items = (SourceSpec *)realloc(g_sources.items, snap.count * sizeof(SourceSpec));
        if (!new_items && snap.count > 0) {
            /* Keep the popped snapshot so the user can retry after freeing memory. */
            if (g_source_undo_count < SOURCE_UNDO_DEPTH) {
                g_source_undo_stack[g_source_undo_count++] = snap;
            }
            MessageBeep(MB_ICONERROR);
            set_status("Undo failed: out of memory while restoring %zu source node(s).", snap.count);
            return 0;
        }
        g_sources.items = new_items;
        g_sources.capacity = snap.count;
    }
    if (snap.count > 0 && snap.items) {
        memcpy(g_sources.items, snap.items, snap.count * sizeof(SourceSpec));
    }
    g_sources.count = snap.count;
    memcpy(g_stamp_faults, snap.stamp_faults, sizeof(g_stamp_faults));
    g_stamp_fault_count = snap.stamp_fault_count;
    g_current_stamp_fault = snap.current_stamp_fault;
    g_next_stamp_patch_id = snap.next_stamp_patch_id;
    g_selected_source_i = snap.selected_source_i;
    if (!source_index_is_valid(g_selected_source_i)) g_selected_source_i = (g_sources.count > 0) ? 0 : -1;
    g_rupture_delay_cursor_s = snap.rupture_delay_cursor_s;
    g_drag_start_delay_cursor_s = snap.drag_start_delay_cursor_s;
    g_active_paint_delay_override_s = snap.active_paint_delay_override_s;

    node_tune_clear_selection();
    if (g_node_tune_mode && source_index_is_valid(g_selected_source_i)) node_tune_select_only(g_selected_source_i);
    reset_state_preserve_sources();
    set_status("Undo: restored before '%s'. Source nodes: %zu.", snap.label[0] ? snap.label : "source edit", g_sources.count);
    source_undo_free_snapshot(&snap);
    if (hwnd) InvalidateRect(hwnd, NULL, FALSE);
    return 1;
}


static size_t count_solid_sources(void) {
    size_t blocked = 0;
    for (size_t i = 0; i < g_sources.count; ++i) {
        int x = g_sources.items[i].grid_x;
        int y = g_sources.items[i].grid_y;
        if (x < 0 || y < 0 || x >= (int)g_grid.width || y >= (int)g_grid.height) {
            blocked++;
            continue;
        }
        if (g_grid.solid[tsunami_index(&g_grid, x, y)]) blocked++;
    }
    return blocked;
}

static void rebuild_mask_and_reset(void) {
    rebuild_solid_mask(&g_grid, g_coast_guard_cells);
    sanitize_all_source_node_slips();
    reset_simulation(&g_grid, &g_sim);
    apply_sources_to_simulation(&g_grid, &g_sim, &g_sources);
    g_time_s = 0;
    jesim_reset_export_state();
    time_series_clear_points();
    time_series_record_current();
    size_t blocked = count_solid_sources();
    set_status("Coast guard = %d cell(s). Reset. %zu source point(s) are now blocked/ignored.",
               g_coast_guard_cells, blocked);
}

static void rebuild_land_and_solver_mask_reset(const char *reason) {
    rebuild_land_mask_from_coast_stations(&g_grid);
    rebuild_solid_mask(&g_grid, g_coast_guard_cells);
    sanitize_all_source_node_slips();
    reset_simulation(&g_grid, &g_sim);
    apply_sources_to_simulation(&g_grid, &g_sim, &g_sources);
    g_time_s = 0;
    jesim_reset_export_state();
    time_series_clear_points();
    time_series_record_current();
    size_t blocked = count_solid_sources();
    set_status("%s Overrides=%zu. Reset. %zu source point(s) blocked/ignored.",
               reason ? reason : "Rebuilt corrected coastline mask.",
               count_land_overrides(&g_grid), blocked);
}

/*
   Some cells at the far-east open-boundary gate are solver-only handoff cells.
   They are intentionally not observation points, and their local height/depth can
   become visually misleading because they connect the visible map to the hidden
   absorber.  Do not let those cells affect the visible wave color scale, and do
   not draw the wave overlay there.  The solver still uses them normally.
*/
static int is_display_excluded_cell(size_t grid_idx) {
    if (!g_grid.edge_absorb_gate) return 0;
    if (grid_idx >= g_sim.cells) return 0;
    return g_grid.edge_absorb_gate[grid_idx] != 0;
}

/*
   Forward declarations for max-wave PNG/arrival helpers.
   These functions are defined below, but max-wave helper code also needs them.
*/
static int display_cell_is_land(size_t idx);
static uint32_t blend_rgb32(uint32_t base, uint32_t overlay, float alpha);
static uint32_t tsunami_wave_color_log(float h);

static float current_global_max_abs(void) {
    float max_h = 0.0f;
    for (size_t i = 0; i < g_sim.cells; ++i) {
        if (is_display_excluded_cell(i)) continue;
        float h = fabsf(g_sim.display_height_m[i + g_grid.width]);
        if (h > max_h) max_h = h;
    }
    return max_h;
}


static float current_global_max_positive_wave(void) {
    float max_h = 0.0f;
    if (!g_sim.display_max_m) return 0.0f;
    for (size_t i = 0; i < g_sim.cells; ++i) {
        if (is_display_excluded_cell(i)) continue;
        float h = g_sim.display_max_m[i + g_grid.width];
        if (h > max_h) max_h = h;
    }
    return max_h;
}

static const char *wave_layer_name(void) {
    if (g_wave_layer_mode == WAVE_LAYER_MAX_POSITIVE) return "max positive wave";
    if (g_wave_layer_mode == WAVE_LAYER_MAX_WITH_ARRIVAL) return "max wave + coastal-eq arrival";
    return "live waves";
}

static const char *coast_color_mode_name(void) {
    if (g_coast_color_step) return "step";
    if (g_coast_color_smooth) return "smooth";
    return "linear";
}

static int ocean_arrival_time_for_contour(uint32_t gx, uint32_t gy) {
    /*
       Arrival is stored when the ocean-cell wave, converted to 1m-depth coastal
       equivalent height, reaches 5 cm.  Display contours can therefore use all
       ocean cells without requiring the cell itself to be on the coastline.
    */
    if (!g_sim.display_arrival_s) return 0;
    if (gx >= g_grid.width || gy >= g_grid.height) return 0;

    size_t idx = (size_t)gy * g_grid.width + gx;
    if (is_display_excluded_cell(idx)) return 0;
    if (display_cell_is_land(idx)) return 0;

    return g_sim.display_arrival_s[idx + g_grid.width];
}

static int arrival_contour_for_grid_cell(uint32_t gx, uint32_t gy) {
    /*
       Thin ocean-cell arrival contours using coastal-equivalent 5 cm arrival.

       Return:
         0 = no contour
         1 = 10-minute contour, light
         2 = 30-minute contour, medium
         3 = 60-minute contour, strong

       The threshold is not local offshore eta.  It is eta converted to 1m-depth
       coastal equivalent, so deep-ocean cells can still participate naturally.
    */
    int arrival = ocean_arrival_time_for_contour(gx, gy);
    if (arrival <= 0) return 0;

    const int ten_min = 10 * 60;
    const int thirty_min = 30 * 60;
    const int sixty_min = 60 * 60;

    /*
       Thin tolerance.  Use roughly one solver step so contours do not become
       broad bands in deep water.
    */
    int tol = g_dt_s + 5;
    if (tol < 30) tol = 30;
    if (tol > 50) tol = 50;

    int d60 = arrival % sixty_min;
    if (d60 > sixty_min / 2) d60 = sixty_min - d60;
    if (d60 <= tol) return 3;

    int d30 = arrival % thirty_min;
    if (d30 > thirty_min / 2) d30 = thirty_min - d30;
    if (d30 <= tol) return 2;

    int d10 = arrival % ten_min;
    if (d10 > ten_min / 2) d10 = ten_min - d10;
    if (d10 <= tol) return 1;

    /*
       Very light boundary-crossing fallback.  Only 4-neighbor, no 5x5 filling,
       so the contours stay thin.
    */
    const int dx4[4] = {1, -1, 0, 0};
    const int dy4[4] = {0, 0, 1, -1};
    int bucket = arrival / ten_min;

    for (int k = 0; k < 4; ++k) {
        int nx = (int)gx + dx4[k];
        int ny = (int)gy + dy4[k];
        if (nx < 0 || ny < 0 || nx >= (int)g_grid.width || ny >= (int)g_grid.height) continue;

        int na = ocean_arrival_time_for_contour((uint32_t)nx, (uint32_t)ny);
        if (na <= 0) continue;

        int nb = na / ten_min;
        if (nb != bucket) {
            if ((bucket % 6) == 0 || (nb % 6) == 0) return 3;
            if ((bucket % 3) == 0 || (nb % 3) == 0) return 2;
            return 1;
        }
    }

    return 0;
}

static uint32_t max_wave_rgb_for_grid_cell(uint32_t gx, uint32_t gy, int with_arrival_contour) {
    size_t grid_idx = (size_t)gy * g_grid.width + gx;

    if (display_cell_is_land(grid_idx)) return rgb32(36, 36, 42);
    if (is_display_excluded_cell(grid_idx)) return rgb32(8, 16, 28);

    float h = g_sim.display_max_m ? g_sim.display_max_m[grid_idx + g_grid.width] : 0.0f;
    uint32_t c = (h >= 0.002f) ? tsunami_wave_color_log(h) : rgb32(5, 20, 45);

    if (with_arrival_contour) {
        int contour = arrival_contour_for_grid_cell(gx, gy);
        if (contour == 3) c = blend_rgb32(c, rgb32(255, 255, 255), 0.82f);      /* 60 min */
        else if (contour == 2) c = blend_rgb32(c, rgb32(245, 250, 255), 0.55f); /* 30 min */
        else if (contour == 1) c = blend_rgb32(c, rgb32(215, 235, 255), 0.28f); /* 10 min */
    }

    return c;
}



static int hex_digit_value_gui(char c) {
    if (c >= '0' && c <= '9') return c - '0';
    if (c >= 'a' && c <= 'f') return 10 + (c - 'a');
    if (c >= 'A' && c <= 'F') return 10 + (c - 'A');
    return -1;
}

static int parse_hex_rgb_gui(const char *p, int *r, int *g, int *b) {
    if (!p || *p != '#') return 0;
    int v[6];
    for (int i = 0; i < 6; ++i) {
        v[i] = hex_digit_value_gui(p[1 + i]);
        if (v[i] < 0) return 0;
    }
    *r = v[0] * 16 + v[1];
    *g = v[2] * 16 + v[3];
    *b = v[4] * 16 + v[5];
    return 1;
}

static void sort_color_keyframes(ColorKeyframe *keys, int count) {
    for (int i = 0; i < count - 1; ++i) {
        for (int j = i + 1; j < count; ++j) {
            if (keys[j].height_m < keys[i].height_m) {
                ColorKeyframe tmp = keys[i];
                keys[i] = keys[j];
                keys[j] = tmp;
            }
        }
    }
}

static int parse_color_keyframes_text(const char *text,
                                      ColorKeyframe *out,
                                      int max_count,
                                      int *out_count,
                                      char *err,
                                      size_t err_size) {
    int count = 0;
    const char *p = text ? text : "";

    while (*p) {
        while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n' || *p == ';') p++;
        if (!*p) break;

        char *end = NULL;
        float h = strtof(p, &end);
        if (end == p) {
            if (err && err_size) snprintf(err, err_size, "Expected height number near: %.24s", p);
            return 0;
        }

        const char *hash = strchr(end, '#');
        if (!hash) {
            if (err && err_size) snprintf(err, err_size, "Missing #RRGGBB color after height %.3g", h);
            return 0;
        }

        int r = 0, g = 0, b = 0;
        if (!parse_hex_rgb_gui(hash, &r, &g, &b)) {
            if (err && err_size) snprintf(err, err_size, "Bad color near height %.3g. Use #RRGGBB.", h);
            return 0;
        }

        if (count >= max_count) {
            if (err && err_size) snprintf(err, err_size, "Too many settings. Max is %d.", max_count);
            return 0;
        }

        out[count].height_m = h;
        out[count].r = r;
        out[count].g = g;
        out[count].b = b;
        count++;

        p = hash + 7;
        while (*p && *p != '\n' && *p != ';') p++;
    }

    if (count < 2) {
        if (err && err_size) snprintf(err, err_size, "Need at least two settings.");
        return 0;
    }

    sort_color_keyframes(out, count);
    for (int i = 1; i < count; ++i) {
        if (fabsf(out[i].height_m - out[i - 1].height_m) < 0.000001f) {
            if (err && err_size) snprintf(err, err_size, "Duplicate height %.3g.", out[i].height_m);
            return 0;
        }
    }

    *out_count = count;
    return 1;
}

static uint32_t color_keyframes_rgb32(const ColorKeyframe *keys, int count, int smooth, float height_m) {
    if (!keys || count <= 0) return rgb32(0, 0, 0);

    if (height_m <= keys[0].height_m) return rgb32(keys[0].r, keys[0].g, keys[0].b);
    if (height_m >= keys[count - 1].height_m) {
        return rgb32(keys[count - 1].r, keys[count - 1].g, keys[count - 1].b);
    }

    for (int i = 0; i < count - 1; ++i) {
        if (height_m >= keys[i].height_m && height_m <= keys[i + 1].height_m) {
            float span = keys[i + 1].height_m - keys[i].height_m;
            float u = span > 0.0f ? (height_m - keys[i].height_m) / span : 0.0f;
            u = clampf_gui(u, 0.0f, 1.0f);
            if (smooth) u = u * u * (3.0f - 2.0f * u);

            int r = (int)(keys[i].r + (keys[i + 1].r - keys[i].r) * u + 0.5f);
            int g = (int)(keys[i].g + (keys[i + 1].g - keys[i].g) * u + 0.5f);
            int b = (int)(keys[i].b + (keys[i + 1].b - keys[i].b) * u + 0.5f);
            return rgb32(r, g, b);
        }
    }

    return rgb32(keys[count - 1].r, keys[count - 1].g, keys[count - 1].b);
}

static COLORREF color_keyframes_colorref(const ColorKeyframe *keys, int count, int smooth, float height_m) {
    uint32_t c = color_keyframes_rgb32(keys, count, smooth, height_m);
    return RGB((c >> 16) & 0xffu, (c >> 8) & 0xffu, c & 0xffu);
}

static COLORREF color_keyframes_step_colorref(const ColorKeyframe *keys, int count, float height_m) {
    if (!keys || count <= 0) return RGB(0, 0, 0);

    if (height_m < keys[0].height_m) return RGB(0, 0, 0);

    int chosen = 0;
    for (int i = 1; i < count; ++i) {
        if (height_m >= keys[i].height_m) chosen = i;
        else break;
    }

    return RGB(keys[chosen].r, keys[chosen].g, keys[chosen].b);
}

static const char *default_cell_color_keyframes_text(void) {
    /*
       Actual-height keyframes.  Until the user presses Apply colors, the old
       built-in wave color function is still used, so the default appearance is
       exactly preserved.
    */
    return
        "-1.0,#0037ff\r\n"
        "0.0,#78dcff\r\n"
        "0.5,#3cffd2\r\n"
        "1.0,#14f050\r\n"
        "2.0,#e6ff28\r\n"
        "5.0,#ff2823\r\n"
        "10.0,#d700aa\r\n"
        "15.0,#9600be\r\n"
        "20.0,#46005f\r\n";
}

static const char *default_coast_color_keyframes_text(void) {
    return
        "0.05,#99ff80\r\n"
        "0.10,#00ff00\r\n"
        "0.20,#fff280\r\n"
        "0.50,#ffe600\r\n"
        "1.00,#ff8080\r\n"
        "2.00,#ff0000\r\n"
        "3.00,#e580ff\r\n"
        "4.00,#cc00ff\r\n"
        "5.00,#8b00ad\r\n"
        "8.00,#460057\r\n"
        "10.0,#000000\r\n";
}

static void format_color_keyframes_text(const ColorKeyframe *keys,
                                        int count,
                                        const char *fallback,
                                        char *out,
                                        size_t out_size) {
    if (!out || out_size == 0) return;
    out[0] = '\0';

    if (!keys || count <= 0) {
        snprintf(out, out_size, "%s", fallback ? fallback : "");
        return;
    }

    size_t used = 0;
    for (int i = 0; i < count; ++i) {
        int n = snprintf(out + used, out_size - used,
                         "%.4g,#%02x%02x%02x\r\n",
                         keys[i].height_m, keys[i].r, keys[i].g, keys[i].b);
        if (n < 0) break;
        if ((size_t)n >= out_size - used) {
            used = out_size - 1;
            break;
        }
        used += (size_t)n;
    }
}


static char *trim_ascii_in_place(char *s) {
    if (!s) return s;
    while (*s == ' ' || *s == '\t' || *s == '\r' || *s == '\n') s++;
    char *end = s + strlen(s);
    while (end > s && (end[-1] == ' ' || end[-1] == '\t' || end[-1] == '\r' || end[-1] == '\n')) {
        *--end = '\0';
    }
    return s;
}

static void color_settings_default_path(char *out, size_t out_size) {
    if (!out || out_size == 0) return;
    out[0] = '\0';

    char exe_path[MAX_PATH];
    DWORD n = GetModuleFileNameA(NULL, exe_path, MAX_PATH);
    if (n > 0 && n < MAX_PATH) {
        const char *slash1 = strrchr(exe_path, '\\');
        const char *slash2 = strrchr(exe_path, '/');
        const char *slash = slash1 > slash2 ? slash1 : slash2;
        if (slash) {
            size_t dir_len = (size_t)(slash - exe_path + 1);
            if (dir_len >= out_size) dir_len = out_size - 1;
            snprintf(out, out_size, "%.*s%s", (int)dir_len, exe_path, "tsunami_color_settings.ini");
            return;
        }
    }

    snprintf(out, out_size, "%s", "tsunami_color_settings.ini");
}

static void append_color_settings_line(char *dst, size_t dst_size, const char *line) {
    if (!dst || dst_size == 0 || !line) return;
    size_t used = strlen(dst);
    if (used >= dst_size - 1) return;

    size_t len = strlen(line);
    while (len > 0 && (line[len - 1] == '\r' || line[len - 1] == '\n')) len--;

    if (used + len + 2 >= dst_size) {
        len = (dst_size > used + 2) ? (dst_size - used - 2) : 0;
    }
    if (len > 0) {
        memcpy(dst + used, line, len);
        used += len;
    }
    if (used < dst_size - 1) dst[used++] = '\n';
    dst[used] = '\0';
}

static void write_color_keyframes_file_block(FILE *fp,
                                             const char *begin_tag,
                                             const char *end_tag,
                                             const ColorKeyframe *keys,
                                             int count,
                                             const char *fallback) {
    fprintf(fp, "%s\n", begin_tag);
    if (keys && count > 0) {
        for (int i = 0; i < count; ++i) {
            fprintf(fp, "%.9g,#%02x%02x%02x\n",
                    keys[i].height_m,
                    clampi_gui(keys[i].r, 0, 255),
                    clampi_gui(keys[i].g, 0, 255),
                    clampi_gui(keys[i].b, 0, 255));
        }
    } else if (fallback) {
        const char *p = fallback;
        while (*p) {
            const char *e = p;
            while (*e && *e != '\r' && *e != '\n') e++;
            if (e > p) fprintf(fp, "%.*s\n", (int)(e - p), p);
            while (*e == '\r' || *e == '\n') e++;
            p = e;
        }
    }
    fprintf(fp, "%s\n", end_tag);
}

static int auto_save_color_settings(void) {
    char path[MAX_PATH];
    color_settings_default_path(path, sizeof(path));
    FILE *fp = fopen(path, "wb");
    if (!fp) return 0;

    fprintf(fp, "TSUNAMI_COLOR_SETTINGS_V1\n");
    fprintf(fp, "cell_enabled=%d\n", g_cell_color_custom_enabled ? 1 : 0);
    fprintf(fp, "cell_smooth=%d\n", g_cell_color_smooth ? 1 : 0);
    fprintf(fp, "coast_enabled=%d\n", g_coast_color_custom_enabled ? 1 : 0);
    fprintf(fp, "coast_smooth=%d\n", g_coast_color_smooth ? 1 : 0);
    fprintf(fp, "coast_step=%d\n", g_coast_color_step ? 1 : 0);
    fprintf(fp, "\n");

    write_color_keyframes_file_block(fp, "CELL_BEGIN", "CELL_END",
                                     (g_cell_color_custom_enabled && g_cell_color_key_count > 0) ? g_cell_color_keys : NULL,
                                     (g_cell_color_custom_enabled && g_cell_color_key_count > 0) ? g_cell_color_key_count : 0,
                                     default_cell_color_keyframes_text());
    fprintf(fp, "\n");
    write_color_keyframes_file_block(fp, "COAST_BEGIN", "COAST_END",
                                     (g_coast_color_custom_enabled && g_coast_color_key_count > 0) ? g_coast_color_keys : NULL,
                                     (g_coast_color_custom_enabled && g_coast_color_key_count > 0) ? g_coast_color_key_count : 0,
                                     default_coast_color_keyframes_text());

    fclose(fp);
    return 1;
}

static int auto_load_color_settings(void) {
    char path[MAX_PATH];
    color_settings_default_path(path, sizeof(path));

    FILE *fp = fopen(path, "rb");
    if (!fp) return 0;

    char cell_text[8192];
    char coast_text[8192];
    cell_text[0] = '\0';
    coast_text[0] = '\0';

    int cell_enabled = -1;
    int coast_enabled = -1;
    int cell_smooth = 0;
    int coast_smooth = 0;
    int coast_step = 0;
    int in_block = 0; /* 0 none, 1 cell, 2 coast */
    int saw_cell_block = 0;
    int saw_coast_block = 0;

    char line[512];
    while (fgets(line, sizeof(line), fp)) {
        char *p = line;
        if ((unsigned char)p[0] == 0xef && (unsigned char)p[1] == 0xbb && (unsigned char)p[2] == 0xbf) p += 3;
        p = trim_ascii_in_place(p);

        if (in_block == 1) {
            if (strcmp(p, "CELL_END") == 0) {
                in_block = 0;
            } else if (*p && *p != '#') {
                append_color_settings_line(cell_text, sizeof(cell_text), p);
                saw_cell_block = 1;
            }
            continue;
        }
        if (in_block == 2) {
            if (strcmp(p, "COAST_END") == 0) {
                in_block = 0;
            } else if (*p && *p != '#') {
                append_color_settings_line(coast_text, sizeof(coast_text), p);
                saw_coast_block = 1;
            }
            continue;
        }

        if (*p == '\0' || *p == '#') continue;
        if (strcmp(p, "CELL_BEGIN") == 0) {
            in_block = 1;
            continue;
        }
        if (strcmp(p, "COAST_BEGIN") == 0) {
            in_block = 2;
            continue;
        }

        int v = 0;
        if (sscanf(p, "cell_enabled=%d", &v) == 1) cell_enabled = v ? 1 : 0;
        else if (sscanf(p, "cell_smooth=%d", &v) == 1) cell_smooth = v ? 1 : 0;
        else if (sscanf(p, "coast_enabled=%d", &v) == 1) coast_enabled = v ? 1 : 0;
        else if (sscanf(p, "coast_smooth=%d", &v) == 1) coast_smooth = v ? 1 : 0;
        else if (sscanf(p, "coast_step=%d", &v) == 1) coast_step = v ? 1 : 0;
    }

    fclose(fp);

    ColorKeyframe cell_tmp[MAX_COLOR_KEYFRAMES];
    ColorKeyframe coast_tmp[MAX_COLOR_KEYFRAMES];
    int cell_count = 0;
    int coast_count = 0;
    char err[256];

    if (cell_enabled != 0 && saw_cell_block) {
        if (!parse_color_keyframes_text(cell_text, cell_tmp, MAX_COLOR_KEYFRAMES, &cell_count, err, sizeof(err))) {
            return 0;
        }
        memcpy(g_cell_color_keys, cell_tmp, sizeof(ColorKeyframe) * (size_t)cell_count);
        g_cell_color_key_count = cell_count;
        g_cell_color_custom_enabled = 1;
    } else {
        g_cell_color_key_count = 0;
        g_cell_color_custom_enabled = 0;
    }

    if (coast_enabled != 0 && saw_coast_block) {
        if (!parse_color_keyframes_text(coast_text, coast_tmp, MAX_COLOR_KEYFRAMES, &coast_count, err, sizeof(err))) {
            return 0;
        }
        memcpy(g_coast_color_keys, coast_tmp, sizeof(ColorKeyframe) * (size_t)coast_count);
        g_coast_color_key_count = coast_count;
        g_coast_color_custom_enabled = 1;
    } else {
        g_coast_color_key_count = 0;
        g_coast_color_custom_enabled = 0;
    }

    g_cell_color_smooth = cell_smooth ? 1 : 0;
    g_coast_color_step = coast_step ? 1 : 0;
    g_coast_color_smooth = g_coast_color_step ? 0 : (coast_smooth ? 1 : 0);

    return 1;
}

static char *get_window_text_alloc(HWND hwnd) {
    int len = GetWindowTextLengthA(hwnd);
    if (len < 0) len = 0;
    char *buf = (char *)malloc((size_t)len + 2u);
    if (!buf) return NULL;
    GetWindowTextA(hwnd, buf, len + 1);
    return buf;
}

static int apply_color_keyframes_from_edits(HWND hwnd) {
    HWND cell_edit = GetDlgItem(hwnd, ID_SET_EDIT_CELL_COLORS);
    HWND coast_edit = GetDlgItem(hwnd, ID_SET_EDIT_COAST_COLORS);
    if (!cell_edit || !coast_edit) return 0;

    char *cell_text = get_window_text_alloc(cell_edit);
    char *coast_text = get_window_text_alloc(coast_edit);
    if (!cell_text || !coast_text) {
        free(cell_text);
        free(coast_text);
        MessageBoxA(hwnd, "Could not read color settings text.", "Color settings", MB_OK | MB_ICONWARNING);
        return 0;
    }

    ColorKeyframe cell_tmp[MAX_COLOR_KEYFRAMES];
    ColorKeyframe coast_tmp[MAX_COLOR_KEYFRAMES];
    int cell_count = 0;
    int coast_count = 0;
    char err[256];

    if (!parse_color_keyframes_text(cell_text, cell_tmp, MAX_COLOR_KEYFRAMES, &cell_count, err, sizeof(err))) {
        MessageBoxA(hwnd, err, "Cell color settings", MB_OK | MB_ICONWARNING);
        free(cell_text);
        free(coast_text);
        return 0;
    }

    if (!parse_color_keyframes_text(coast_text, coast_tmp, MAX_COLOR_KEYFRAMES, &coast_count, err, sizeof(err))) {
        MessageBoxA(hwnd, err, "Coastal marker color settings", MB_OK | MB_ICONWARNING);
        free(cell_text);
        free(coast_text);
        return 0;
    }

    memcpy(g_cell_color_keys, cell_tmp, sizeof(ColorKeyframe) * (size_t)cell_count);
    memcpy(g_coast_color_keys, coast_tmp, sizeof(ColorKeyframe) * (size_t)coast_count);
    g_cell_color_key_count = cell_count;
    g_coast_color_key_count = coast_count;
    g_cell_color_custom_enabled = 1;
    g_coast_color_custom_enabled = 1;

    free(cell_text);
    free(coast_text);
    if (auto_save_color_settings()) {
        settings_changed("Applied custom color settings and saved for next launch.");
    } else {
        settings_changed("Applied custom color settings, but could not save them.");
    }
    return 1;
}

static void reset_color_keyframes_to_builtin(HWND hwnd) {
    g_cell_color_custom_enabled = 0;
    g_coast_color_custom_enabled = 0;
    g_cell_color_key_count = 0;
    g_coast_color_key_count = 0;
    g_cell_color_smooth = 0;
    g_coast_color_smooth = 0;
    g_coast_color_step = 0;

    HWND cell_edit = GetDlgItem(hwnd, ID_SET_EDIT_CELL_COLORS);
    HWND coast_edit = GetDlgItem(hwnd, ID_SET_EDIT_COAST_COLORS);
    if (cell_edit) SetWindowTextA(cell_edit, default_cell_color_keyframes_text());
    if (coast_edit) SetWindowTextA(coast_edit, default_coast_color_keyframes_text());

    if (auto_save_color_settings()) {
        settings_changed("Restored built-in colors and saved for next launch.");
    } else {
        settings_changed("Restored built-in colors, but could not save color settings.");
    }
}

static COLORREF custom_station_color_for_height(float height_m, int *visible) {
    float h = height_m > 0.0f ? height_m : 0.0f;
    if (g_coast_color_key_count < 2) {
        if (visible) *visible = 0;
        return RGB(0, 0, 0);
    }

    if (h < g_coast_color_keys[0].height_m) {
        if (visible) *visible = 0;
        return RGB(0, 0, 0);
    }

    if (visible) *visible = 1;
    if (g_coast_color_step) {
        return color_keyframes_step_colorref(g_coast_color_keys, g_coast_color_key_count, h);
    }
    return color_keyframes_colorref(g_coast_color_keys, g_coast_color_key_count, g_coast_color_smooth, h);
}

static COLORREF original_station_color_for_height(float height_m, int *visible) {
    if (g_coast_color_custom_enabled && g_coast_color_key_count >= 2) {
        return custom_station_color_for_height(height_m, visible);
    }

    /* Coastal observation markers follow forecast-height logic: only positive
       water-level rise is shown.  Negative drawdown is ignored here so the
       subsidence side of a reverse-fault source does not trigger warning colors. */
    float h = height_m > 0.0f ? height_m : 0.0f;
    if (visible) *visible = (h >= 0.05f);

    /* Original PenguinMod station/warning costume colors:
       0-5cm invisible, then 5-10cm #99ff80, 10-20cm #00ff00, ... */
    if (h < 0.05f) return RGB(0, 0, 0);
    if (h < 0.10f) return RGB(0x99, 0xff, 0x80);
    if (h < 0.20f) return RGB(0x00, 0xff, 0x00);
    if (h < 0.50f) return RGB(0xff, 0xf2, 0x80);
    if (h < 1.00f) return RGB(0xff, 0xe6, 0x00);
    if (h < 2.00f) return RGB(0xff, 0x80, 0x80);
    if (h < 3.00f) return RGB(0xff, 0x00, 0x00);
    if (h < 4.00f) return RGB(0xe5, 0x80, 0xff);
    if (h < 5.00f) return RGB(0xcc, 0x00, 0xff);
    if (h < 8.00f) return RGB(0x8b, 0x00, 0xad);
    if (h < 10.0f) return RGB(0x46, 0x00, 0x57);
    return RGB(0x00, 0x00, 0x00);
}

static float current_forecast_zone_max_height(void) {
    float max_h = 0.0f;
    for (uint32_t i = 0; i < g_grid.station_count; ++i) {
        if (!station_is_enabled(&g_grid, i)) continue;
        int region_id = g_grid.station_id[i];
        if (region_id < 1 || region_id > 93 || region_id == 67) continue;
        if (g_sim.station_max_m[i] > max_h) max_h = g_sim.station_max_m[i];
    }
    return max_h;
}

static int station_zone_id_for_display(int station_i);
static const char *station_zone_name_for_display(int station_i);

static int display_cell_is_land(size_t idx) {
    int land = g_grid.land[idx] ? 1 : 0;
    if (g_invert_landsea_display) land = !land;
    return land;
}

static int is_land_coast_edge(uint32_t x, uint32_t y) {
    if (x >= g_grid.width || y >= g_grid.height) return 0;
    size_t idx = (size_t)y * g_grid.width + x;
    if (!display_cell_is_land(idx)) return 0;
    static const int dx[4] = {-1, 1, 0, 0};
    static const int dy[4] = {0, 0, -1, 1};
    for (int k = 0; k < 4; ++k) {
        int nx = (int)x + dx[k];
        int ny = (int)y + dy[k];
        if (nx < 0 || ny < 0 || nx >= (int)g_grid.width || ny >= (int)g_grid.height) continue;
        size_t nidx = (size_t)ny * g_grid.width + (size_t)nx;
        if (!display_cell_is_land(nidx)) return 1;
    }
    return 0;
}

static float wave_color_input_height(float h) {
    /*
       Visual-only coefficient for the color lookup.
       It does not change the simulated water level, station values, CSV output,
       max-wave storage, or arrival-time logic.
    */
    return h * g_wave_display_coeff;
}

static float tsunami_display_canonical_height(float h) {
    /*
       Manual display scale:
       g_wave_color_max_m corresponds to the +20 m color-table endpoint.
       Negative drawdown is display-clamped at -1 m only.
       This is display-only. The simulation field itself is not modified.
    */
    if (h < 0.0f) {
        if (h < -1.0f) return -1.0f;
        return h;
    }

    float max_m = g_wave_color_max_m;
    if (g_wave_display_coeff > 0.0001f) max_m /= g_wave_display_coeff;
    if (max_m < 0.01f) max_m = 0.01f;
    return h * (20.0f / max_m);
}

static uint32_t lerp_rgb32(float u,
                           int r0, int g0, int b0,
                           int r1, int g1, int b1) {
    u = clampf_gui(u, 0.0f, 1.0f);
    int r = (int)(r0 + (r1 - r0) * u + 0.5f);
    int g = (int)(g0 + (g1 - g0) * u + 0.5f);
    int b = (int)(b0 + (b1 - b0) * u + 0.5f);
    return rgb32(r, g, b);
}

static uint32_t blend_rgb32(uint32_t base, uint32_t overlay, float alpha) {
    alpha = clampf_gui(alpha, 0.0f, 1.0f);
    int br = (int)((base >> 16) & 0xffu);
    int bg = (int)((base >> 8) & 0xffu);
    int bb = (int)(base & 0xffu);
    int orr = (int)((overlay >> 16) & 0xffu);
    int og = (int)((overlay >> 8) & 0xffu);
    int ob = (int)(overlay & 0xffu);
    int r = (int)(br * (1.0f - alpha) + orr * alpha + 0.5f);
    int g = (int)(bg * (1.0f - alpha) + og * alpha + 0.5f);
    int b = (int)(bb * (1.0f - alpha) + ob * alpha + 0.5f);
    return rgb32(r, g, b);
}

static uint32_t base_ocean_color(size_t grid_idx) {
    if (g_show_bathymetry) {
        float depth = clampf_gui(g_grid.depth_m[grid_idx], 0.0f, 7000.0f);
        float t = depth / 7000.0f;
        int r = (int)(8 + 18 * (1.0f - t));
        int g = (int)(42 + 82 * (1.0f - t));
        int b = (int)(86 + 128 * t);
        return rgb32(r, g, b);
    }
    return rgb32(17, 44, 88);
}

static uint32_t tsunami_wave_color_log(float h) {
    h = wave_color_input_height(h);

    if (g_cell_color_custom_enabled && g_cell_color_key_count >= 2) {
        return color_keyframes_rgb32(g_cell_color_keys, g_cell_color_key_count, g_cell_color_smooth, h);
    }

    /*
       Stable direct-RGB table.  This intentionally avoids HSV and COLORREF
       conversion for the wave overlay, because earlier versions could look
       channel-swapped or hue-wrapped on some builds.

       Meaning:
         negative drawdown -> blue side
         0 m               -> sky blue center
         positive rise      -> green/yellow/red/purple side

       Canonical levels:
         -1, 0, 0.5, 1, 2, 5, 10, 15, 20 m

       Positive side is scaled by g_wave_color_max_m.
    */
    const float level[] = {-1.0f, 0.0f, 0.5f, 1.0f, 2.0f, 5.0f, 10.0f, 15.0f, 20.0f};
    const int rr[] = {  0, 120,  60,  20, 230, 255, 215, 150,  70};
    const int gg[] = { 55, 220, 255, 240, 255,  40,   0,   0,   0};
    const int bb[] = {255, 255, 210,  80,  40,  35, 170, 190,  95};
    const int n = (int)(sizeof(level) / sizeof(level[0]));

    float ch = tsunami_display_canonical_height(h);

    if (ch <= level[0]) return rgb32(rr[0], gg[0], bb[0]);
    if (ch >= level[n - 1]) return rgb32(rr[n - 1], gg[n - 1], bb[n - 1]);

    for (int i = 0; i < n - 1; ++i) {
        if (ch >= level[i] && ch <= level[i + 1]) {
            float u = (ch - level[i]) / (level[i + 1] - level[i]);
            return lerp_rgb32(u, rr[i], gg[i], bb[i], rr[i + 1], gg[i + 1], bb[i + 1]);
        }
    }

    return rgb32(120, 220, 255);
}

static void invalidate_base_render_cache(void) {
    g_base_cache_valid = 0;
}

static void build_base_pixel_buffer_if_needed(void) {
    if (!g_base_pixels) return;

    if (g_base_cache_valid &&
        g_base_cache_flip_x == g_display_flip_x &&
        g_base_cache_flip_y == g_display_flip_y &&
        g_base_cache_bathy == g_show_bathymetry &&
        g_base_cache_invert == g_invert_landsea_display &&
        g_base_cache_overrides == g_show_mask_overrides) {
        return;
    }

    const uint32_t W = g_grid.width;
    const uint32_t H = g_grid.height;

    for (uint32_t y = 0; y < H; ++y) {
        uint32_t gy = g_display_flip_y ? (H - 1u - y) : y;
        size_t pixel_row = (size_t)y * W;
        size_t grid_row = (size_t)gy * W;

        for (uint32_t x = 0; x < W; ++x) {
            uint32_t gx = g_display_flip_x ? (W - 1u - x) : x;
            size_t pixel_idx = pixel_row + x;
            size_t grid_idx = grid_row + gx;

            /*
               Base map must follow the visual land/sea mask, not the raw
               simulation land flag.  Some datasets need g_invert_landsea_display
               ON to look correct.  The previous optimized cache used raw land
               first, so the whole map could become a single ocean color.
            */
            uint32_t color;
            if (display_cell_is_land(grid_idx)) {
                if (is_land_coast_edge(gx, gy)) color = rgb32(214, 196, 116);
                else color = rgb32(178, 162, 94);
            } else {
                color = base_ocean_color(grid_idx);
            }

            if (g_show_mask_overrides && g_grid.user_override && g_grid.user_override[grid_idx] >= 0) {
                if (g_grid.user_override[grid_idx] > 0) {
                    color = rgb32(255, 72, 72);
                } else {
                    color = rgb32(70, 180, 255);
                }
            }
            g_base_pixels[pixel_idx] = color;
        }
    }

    g_base_cache_valid = 1;
    g_base_cache_flip_x = g_display_flip_x;
    g_base_cache_flip_y = g_display_flip_y;
    g_base_cache_bathy = g_show_bathymetry;
    g_base_cache_invert = g_invert_landsea_display;
    g_base_cache_overrides = g_show_mask_overrides;
}

static void render_to_pixel_buffer(void) {
    const uint32_t W = g_grid.width;
    const uint32_t H = g_grid.height;
    const size_t cell_count = (size_t)W * (size_t)H;

    build_base_pixel_buffer_if_needed();

    if (g_base_pixels) {
        memcpy(g_pixels, g_base_pixels, cell_count * sizeof(uint32_t));
    }

    if (g_jesim_transmit_mode) {
        return;
    }

    /*
       Fast render path:
       - Static land/sea/bathymetry is cached.
       - Flat water is not recolored every frame.
       - Only non-trivial wave cells are blended over the cached base.
    */
    if (g_wave_overlay_alpha <= 0.001f) return;

    for (uint32_t y = 0; y < H; ++y) {
        uint32_t gy = g_display_flip_y ? (H - 1u - y) : y;
        size_t pixel_row = (size_t)y * W;
        size_t grid_row = (size_t)gy * W;

        for (uint32_t x = 0; x < W; ++x) {
            uint32_t gx = g_display_flip_x ? (W - 1u - x) : x;
            size_t pixel_idx = pixel_row + x;
            size_t grid_idx = grid_row + gx;

            /*
               Draw waves over the visually displayed ocean.  This keeps the
               default land/sea inversion consistent with the background cache.
            */
            if (display_cell_is_land(grid_idx)) continue;
            if (is_display_excluded_cell(grid_idx)) continue;

            float h = 0.0f;
            uint32_t wave = 0;

            if (g_wave_layer_mode == WAVE_LAYER_LIVE) {
                h = g_sim.display_height_m[grid_idx + g_grid.width];
                if (fabsf(h) < 0.002f) continue;
                wave = tsunami_wave_color_log(h);
            } else {
                h = g_sim.display_max_m ? g_sim.display_max_m[grid_idx + g_grid.width] : 0.0f;
                if (h < 0.002f) continue;
                wave = tsunami_wave_color_log(h);
                if (g_wave_layer_mode == WAVE_LAYER_MAX_WITH_ARRIVAL) {
                    int contour = arrival_contour_for_grid_cell(gx, gy);
                    if (contour == 3) wave = blend_rgb32(wave, rgb32(255, 255, 255), 0.82f);      /* 60 min */
                    else if (contour == 2) wave = blend_rgb32(wave, rgb32(245, 250, 255), 0.55f); /* 30 min */
                    else if (contour == 1) wave = blend_rgb32(wave, rgb32(215, 235, 255), 0.28f); /* 10 min */
                }
            }
            if (g_wave_overlay_alpha >= 0.999f) {
                g_pixels[pixel_idx] = wave;
            } else {
                g_pixels[pixel_idx] = blend_rgb32(g_pixels[pixel_idx], wave, g_wave_overlay_alpha);
            }
        }
    }
}

static void clamp_view_pan_for_window(HWND hwnd) {
    RECT rc;
    GetClientRect(hwnd, &rc);
    int client_w = rc.right - rc.left;
    int client_h = rc.bottom - rc.top;
    int map_area_w = client_w - PANEL_W;
    if (map_area_w < 10) map_area_w = client_w;
    if (client_h < 10) client_h = 10;

    if (g_view_w <= map_area_w) {
        g_view_pan_x = 0.0;
    } else {
        double center_x = (double)(map_area_w - g_view_w) * 0.5;
        double min_pan = (double)(map_area_w - g_view_w) - center_x;
        double max_pan = -center_x;
        if (g_view_pan_x < min_pan) g_view_pan_x = min_pan;
        if (g_view_pan_x > max_pan) g_view_pan_x = max_pan;
    }

    if (g_view_h <= client_h) {
        g_view_pan_y = 0.0;
    } else {
        double center_y = (double)(client_h - g_view_h) * 0.5;
        double min_pan = (double)(client_h - g_view_h) - center_y;
        double max_pan = -center_y;
        if (g_view_pan_y < min_pan) g_view_pan_y = min_pan;
        if (g_view_pan_y > max_pan) g_view_pan_y = max_pan;
    }
}

static void compute_viewport(HWND hwnd) {
    RECT rc;
    GetClientRect(hwnd, &rc);
    int client_w = rc.right - rc.left;
    int client_h = rc.bottom - rc.top;
    int map_area_w = client_w - PANEL_W;
    if (map_area_w < 10) map_area_w = client_w;
    if (client_h < 10) client_h = 10;

    double sx = (double)map_area_w / (double)g_grid.width;
    double sy = (double)client_h / (double)g_grid.height;
    double base_scale = sx < sy ? sx : sy;
    if (base_scale < 0.1) base_scale = 0.1;

    if (g_view_zoom < 1.0) g_view_zoom = 1.0;
    if (g_view_zoom > 64.0) g_view_zoom = 64.0;

    double scale = base_scale * g_view_zoom;
    g_view_w = (int)((double)g_grid.width * scale + 0.5);
    g_view_h = (int)((double)g_grid.height * scale + 0.5);
    if (g_view_w < 1) g_view_w = 1;
    if (g_view_h < 1) g_view_h = 1;

    clamp_view_pan_for_window(hwnd);

    g_view_x = (int)((double)(map_area_w - g_view_w) * 0.5 + g_view_pan_x + 0.5);
    g_view_y = (int)((double)(client_h - g_view_h) * 0.5 + g_view_pan_y + 0.5);
}


static void zoom_view_at_screen_point(HWND hwnd, int mouse_x, int mouse_y, double zoom_factor) {
    if (zoom_factor <= 0.0) return;

    RECT rc;
    GetClientRect(hwnd, &rc);
    int client_w = rc.right - rc.left;
    int client_h = rc.bottom - rc.top;
    int map_area_w = client_w - PANEL_W;
    if (map_area_w < 10) map_area_w = client_w;
    if (client_h < 10) client_h = 10;

    compute_viewport(hwnd);

    double old_zoom = g_view_zoom;
    double new_zoom = old_zoom * zoom_factor;
    if (new_zoom < 1.0) new_zoom = 1.0;
    if (new_zoom > 64.0) new_zoom = 64.0;
    if (fabs(new_zoom - old_zoom) < 0.000001) return;

    double anchor_x = (double)mouse_x;
    double anchor_y = (double)mouse_y;
    if (anchor_x < 0.0 || anchor_x >= (double)map_area_w ||
        anchor_y < 0.0 || anchor_y >= (double)client_h) {
        anchor_x = (double)map_area_w * 0.5;
        anchor_y = (double)client_h * 0.5;
    }

    double rel_x = (anchor_x - (double)g_view_x) / (double)g_view_w;
    double rel_y = (anchor_y - (double)g_view_y) / (double)g_view_h;
    rel_x = clampf_gui((float)rel_x, 0.0f, 1.0f);
    rel_y = clampf_gui((float)rel_y, 0.0f, 1.0f);

    g_view_zoom = new_zoom;

    double sx = (double)map_area_w / (double)g_grid.width;
    double sy = (double)client_h / (double)g_grid.height;
    double base_scale = sx < sy ? sx : sy;
    if (base_scale < 0.1) base_scale = 0.1;

    int new_w = (int)((double)g_grid.width * base_scale * g_view_zoom + 0.5);
    int new_h = (int)((double)g_grid.height * base_scale * g_view_zoom + 0.5);
    if (new_w < 1) new_w = 1;
    if (new_h < 1) new_h = 1;

    double desired_x = anchor_x - rel_x * (double)new_w;
    double desired_y = anchor_y - rel_y * (double)new_h;
    double center_x = (double)(map_area_w - new_w) * 0.5;
    double center_y = (double)(client_h - new_h) * 0.5;

    g_view_w = new_w;
    g_view_h = new_h;
    g_view_pan_x = desired_x - center_x;
    g_view_pan_y = desired_y - center_y;

    compute_viewport(hwnd);
}

static int screen_to_grid(int sx, int sy, int *gx, int *gy) {
    if (sx < g_view_x || sy < g_view_y || sx >= g_view_x + g_view_w || sy >= g_view_y + g_view_h) return 0;
    int x = (int)((double)(sx - g_view_x) * (double)g_grid.width / (double)g_view_w);
    int y = (int)((double)(sy - g_view_y) * (double)g_grid.height / (double)g_view_h);
    x = clampi_gui(x, 0, (int)g_grid.width - 1);
    y = clampi_gui(y, 0, (int)g_grid.height - 1);
    if (g_display_flip_x) x = (int)g_grid.width - 1 - x;
    if (g_display_flip_y) y = (int)g_grid.height - 1 - y;
    *gx = x;
    *gy = y;
    return 1;
}

static int grid_to_screen_x(int gx) {
    int draw_x = g_display_flip_x ? ((int)g_grid.width - 1 - gx) : gx;
    return g_view_x + (int)((double)draw_x * (double)g_view_w / (double)g_grid.width);
}

static int grid_to_screen_y(int gy) {
    int draw_y = g_display_flip_y ? ((int)g_grid.height - 1 - gy) : gy;
    return g_view_y + (int)((double)draw_y * (double)g_view_h / (double)g_grid.height);
}


static int grid_cell_screen_left(int gx) {
    int draw_x = g_display_flip_x ? ((int)g_grid.width - 1 - gx) : gx;
    return g_view_x + (int)((double)draw_x * (double)g_view_w / (double)g_grid.width);
}

static int grid_cell_screen_right(int gx) {
    int draw_x = g_display_flip_x ? ((int)g_grid.width - gx) : (gx + 1);
    return g_view_x + (int)((double)draw_x * (double)g_view_w / (double)g_grid.width);
}

static int grid_cell_screen_top(int gy) {
    int draw_y = g_display_flip_y ? ((int)g_grid.height - 1 - gy) : gy;
    return g_view_y + (int)((double)draw_y * (double)g_view_h / (double)g_grid.height);
}

static int grid_cell_screen_bottom(int gy) {
    int draw_y = g_display_flip_y ? ((int)g_grid.height - gy) : (gy + 1);
    return g_view_y + (int)((double)draw_y * (double)g_view_h / (double)g_grid.height);
}

static int grid_cell_screen_center_x(int gx) {
    int a = grid_cell_screen_left(gx);
    int b = grid_cell_screen_right(gx);
    return (a + b) / 2;
}

static int grid_cell_screen_center_y(int gy) {
    int a = grid_cell_screen_top(gy);
    int b = grid_cell_screen_bottom(gy);
    return (a + b) / 2;
}

static int grid_cell_screen_size_px(void) {
    int cw = (int)((double)g_view_w / (double)g_grid.width + 0.5);
    int ch = (int)((double)g_view_h / (double)g_grid.height + 0.5);
    int s = cw < ch ? cw : ch;
    if (s < 1) s = 1;
    return s;
}


#define HOVER_STATION_RADIUS_PX 12

static int scaled_marker_half_size_px(int base_half_size) {
    /*
       Markers are cell-centered.  At high zoom they should visually fill the
       cell instead of staying attached to the cell's top-left corner.
    */
    int cell = grid_cell_screen_size_px();
    int fill_half = (cell - 1) / 2;
    if (fill_half < base_half_size) fill_half = base_half_size;
    if (fill_half > 32) fill_half = 32;
    return fill_half;
}

static int scaled_hover_radius_px(void) {
    int r = scaled_marker_half_size_px(4) + 8;
    if (r < HOVER_STATION_RADIUS_PX) r = HOVER_STATION_RADIUS_PX;
    return r;
}

static int find_hover_station_at_screen(int mouse_x, int mouse_y) {
    int best_i = -1;
    int hover_r = scaled_hover_radius_px();
    int best_d2 = hover_r * hover_r;

    for (uint32_t i = 0; i < g_grid.station_count; ++i) {
        if (!station_is_enabled(&g_grid, i)) continue;
        int32_t idx = g_grid.station_index[i];
        if (idx < 0 || (size_t)idx >= g_sim.cells) continue;
        int gx = idx % (int)g_grid.width;
        int gy = idx / (int)g_grid.width;
        int sx = grid_cell_screen_center_x(gx);
        int sy = grid_cell_screen_center_y(gy);
        int dx = sx - mouse_x;
        int dy = sy - mouse_y;
        int d2 = dx * dx + dy * dy;
        if (d2 <= best_d2) {
            best_d2 = d2;
            best_i = (int)i;
        }
    }
    return best_i;
}

static int get_station_info(int station_i,
                            int *grid_x, int *grid_y,
                            int *sample_x, int *sample_y,
                            int *region_id,
                            float *live_raw_m,
                            float *live_positive_m,
                            float *peak_positive_m,
                            int *arrival_s) {
    if (station_i < 0 || (uint32_t)station_i >= g_grid.station_count) return 0;
    if (!station_is_enabled(&g_grid, (uint32_t)station_i)) return 0;

    int32_t idx = g_grid.station_index[station_i];
    if (idx < 0 || (size_t)idx >= g_sim.cells) return 0;
    int32_t sample_idx = g_grid.station_sample_index ? g_grid.station_sample_index[station_i] : idx;
    if (sample_idx < 0 || (size_t)sample_idx >= g_sim.cells) sample_idx = idx;

    if (grid_x) *grid_x = idx % (int)g_grid.width;
    if (grid_y) *grid_y = idx / (int)g_grid.width;
    if (sample_x) *sample_x = sample_idx % (int)g_grid.width;
    if (sample_y) *sample_y = sample_idx / (int)g_grid.width;
    if (region_id) *region_id = g_grid.station_id ? g_grid.station_id[station_i] : 0;

    float live = station_observed_height_m(&g_grid, &g_sim, (uint32_t)station_i);
    if (live_raw_m) *live_raw_m = live;
    if (live_positive_m) *live_positive_m = live > 0.0f ? live : 0.0f;
    if (peak_positive_m) *peak_positive_m = g_sim.station_max_m ? g_sim.station_max_m[station_i] : 0.0f;
    if (arrival_s) *arrival_s = g_sim.station_arrival_s ? g_sim.station_arrival_s[station_i] : 0;
    return 1;
}


static StationSeriesRecorder *time_series_find_recorder(int station_i) {
    for (size_t i = 0; i < g_ts_rec_count; ++i) {
        if (g_ts_recorders[i].station_i == station_i) return &g_ts_recorders[i];
    }
    return NULL;
}

static void time_series_clear_points(void) {
    for (size_t i = 0; i < g_ts_rec_count; ++i) {
        g_ts_recorders[i].count = 0;
        g_ts_recorders[i].last_time_s = -1;
    }
}

static void time_series_free(void) {
    for (size_t i = 0; i < g_ts_rec_count; ++i) {
        free(g_ts_recorders[i].points);
        g_ts_recorders[i].points = NULL;
        g_ts_recorders[i].count = 0;
        g_ts_recorders[i].capacity = 0;
        g_ts_recorders[i].last_time_s = -1;
    }
    free(g_ts_recorders);
    g_ts_recorders = NULL;
    g_ts_rec_count = 0;
    g_ts_rec_capacity = 0;
    g_ts_station_i = -1;
}

static int time_series_append_point_to_recorder(StationSeriesRecorder *rec,
                                                int time_s,
                                                float level_m,
                                                float positive_m,
                                                float peak_m) {
    if (!rec) return 0;
    if (rec->count > 0 && rec->last_time_s == time_s) {
        rec->points[rec->count - 1].level_m = level_m;
        rec->points[rec->count - 1].positive_rise_m = positive_m;
        rec->points[rec->count - 1].peak_positive_m = peak_m;
        return 1;
    }
    if (rec->count >= rec->capacity) {
        size_t new_cap = rec->capacity ? rec->capacity * 2u : 4096u;
        StationSeriesPoint *new_points = (StationSeriesPoint *)realloc(rec->points, new_cap * sizeof(StationSeriesPoint));
        if (!new_points) {
            set_status("Time-series recorder ran out of memory. Recording stopped for station #%d.", rec->station_i + 1);
            return 0;
        }
        rec->points = new_points;
        rec->capacity = new_cap;
    }
    rec->points[rec->count].time_s = time_s;
    rec->points[rec->count].level_m = level_m;
    rec->points[rec->count].positive_rise_m = positive_m;
    rec->points[rec->count].peak_positive_m = peak_m;
    rec->count++;
    rec->last_time_s = time_s;
    return 1;
}

static int time_series_record_recorder_current(StationSeriesRecorder *rec) {
    if (!rec || rec->station_i < 0) return 0;
    float live_raw = 0.0f, live_pos = 0.0f, peak_pos = 0.0f;
    if (!get_station_info(rec->station_i, NULL, NULL, NULL, NULL, NULL,
                          &live_raw, &live_pos, &peak_pos, NULL)) {
        return 0;
    }
    return time_series_append_point_to_recorder(rec, g_time_s, live_raw, live_pos, peak_pos);
}

static int time_series_record_current(void) {
    int recorded = 0;
    for (size_t i = 0; i < g_ts_rec_count; ++i) {
        if (time_series_record_recorder_current(&g_ts_recorders[i])) recorded++;
    }
    return recorded;
}

static int time_series_add_station(int station_i, int clear_existing) {
    if (station_i < 0 || (uint32_t)station_i >= g_grid.station_count) return 0;
    if (!station_is_enabled(&g_grid, (uint32_t)station_i)) return 0;
    int gx = 0, gy = 0, sx = 0, sy = 0, region_id = 0;
    if (!get_station_info(station_i, &gx, &gy, &sx, &sy, &region_id, NULL, NULL, NULL, NULL)) return 0;

    if (clear_existing) {
        time_series_free();
    } else {
        StationSeriesRecorder *existing = time_series_find_recorder(station_i);
        if (existing) {
            g_ts_station_i = station_i;
            set_status("Station #%d is already selected. Multi selected: %zu station(s).", station_i + 1, g_ts_rec_count);
            return 1;
        }
    }

    if (g_ts_rec_count >= g_ts_rec_capacity) {
        size_t new_cap = g_ts_rec_capacity ? g_ts_rec_capacity * 2u : 8u;
        StationSeriesRecorder *new_recs = (StationSeriesRecorder *)realloc(g_ts_recorders, new_cap * sizeof(StationSeriesRecorder));
        if (!new_recs) {
            set_status("Could not allocate another time-series station recorder.");
            return 0;
        }
        g_ts_recorders = new_recs;
        g_ts_rec_capacity = new_cap;
    }

    StationSeriesRecorder *rec = &g_ts_recorders[g_ts_rec_count++];
    rec->station_i = station_i;
    rec->points = NULL;
    rec->count = 0;
    rec->capacity = 0;
    rec->last_time_s = -1;
    g_ts_station_i = station_i;
    time_series_record_recorder_current(rec);

    if (clear_existing) {
        set_status("Time-series station selected: #%d, station_id %d, %s. Recording starts at t=%d s.",
                   station_i + 1, region_id, forecast_zone_name_from_id(region_id), g_time_s);
    } else {
        set_status("Added time-series station: #%d, station_id %d, %s. Multi selected: %zu station(s).",
                   station_i + 1, region_id, forecast_zone_name_from_id(region_id), g_ts_rec_count);
    }
    return 1;
}

static int time_series_select_station(int station_i) {
    return time_series_add_station(station_i, 1);
}

static size_t time_series_selected_count(void) {
    return g_ts_rec_count;
}

static size_t time_series_total_samples(void) {
    size_t total = 0;
    for (size_t i = 0; i < g_ts_rec_count; ++i) total += g_ts_recorders[i].count;
    return total;
}

static void csv_write_escaped(FILE *fp, const char *text) {
    fputc('"', fp);
    if (text) {
        for (const unsigned char *p = (const unsigned char *)text; *p; ++p) {
            if (*p == '"') fputc('"', fp);
            fputc(*p, fp);
        }
    }
    fputc('"', fp);
}

static void csv_write_escaped_utf8_from_acp(FILE *fp, const char *text) {
    /*
       CSV export is UTF-8 with BOM.  Text typed into Win32 ANSI EDIT controls
       arrives in the Windows active code page, so convert it to UTF-8 before
       writing.  This prevents Korean/Japanese zone names from breaking in CSV.
    */
    fputc('"', fp);

    if (text && text[0]) {
        int wlen = MultiByteToWideChar(CP_ACP, 0, text, -1, NULL, 0);
        if (wlen > 0) {
            WCHAR *wbuf = (WCHAR *)malloc((size_t)wlen * sizeof(WCHAR));
            if (wbuf) {
                MultiByteToWideChar(CP_ACP, 0, text, -1, wbuf, wlen);
                int u8len = WideCharToMultiByte(CP_UTF8, 0, wbuf, -1, NULL, 0, NULL, NULL);
                if (u8len > 0) {
                    char *u8 = (char *)malloc((size_t)u8len);
                    if (u8) {
                        WideCharToMultiByte(CP_UTF8, 0, wbuf, -1, u8, u8len, NULL, NULL);
                        for (const unsigned char *p = (const unsigned char *)u8; *p; ++p) {
                            if (*p == '"') fputc('"', fp);
                            fputc(*p, fp);
                        }
                        free(u8);
                    }
                }
                free(wbuf);
            }
        } else {
            for (const unsigned char *p = (const unsigned char *)text; *p; ++p) {
                if (*p == '"') fputc('"', fp);
                fputc(*p, fp);
            }
        }
    }

    fputc('"', fp);
}

static void svg_write_escaped(FILE *fp, const char *text) {
    if (!text) return;
    for (const unsigned char *p = (const unsigned char *)text; *p; ++p) {
        switch (*p) {
        case '&': fputs("&amp;", fp); break;
        case '<': fputs("&lt;", fp); break;
        case '>': fputs("&gt;", fp); break;
        case '"': fputs("&quot;", fp); break;
        default: fputc(*p, fp); break;
        }
    }
}

static int write_time_series_csv_for_recorder(const char *path, const StationSeriesRecorder *rec) {
    if (!rec || rec->station_i < 0 || rec->count == 0) return 0;
    FILE *fp = fopen(path, "wb");
    if (!fp) return 0;

    int gx = 0, gy = 0, sx = 0, sy = 0, region_id = 0, arrival_s = 0;
    float live_raw = 0.0f, live_pos = 0.0f, peak_pos = 0.0f;
    get_station_info(rec->station_i, &gx, &gy, &sx, &sy, &region_id,
                     &live_raw, &live_pos, &peak_pos, &arrival_s);

    fprintf(fp, "# station_no,%d\n", rec->station_i + 1);
    fprintf(fp, "# forecast_zone_id,%d\n", region_id);
    fprintf(fp, "# forecast_zone_name,");
    csv_write_escaped(fp, forecast_zone_name_from_id(region_id));
    fprintf(fp, "\n");
    fprintf(fp, "# grid_x,%d\n# grid_y,%d\n# sample_grid_x,%d\n# sample_grid_y,%d\n", gx, gy, sx, sy);
    fprintf(fp, "# note,level_m is signed sea-level change. positive_rise_m ignores drawdown.\n");
    fprintf(fp, "time_s,time_min,time_hr,level_m,level_cm,positive_rise_m,positive_rise_cm,peak_positive_rise_m,peak_positive_rise_cm\n");
    for (size_t i = 0; i < rec->count; ++i) {
        const StationSeriesPoint *pt = &rec->points[i];
        fprintf(fp, "%d,%.6f,%.6f,%.8f,%.5f,%.8f,%.5f,%.8f,%.5f\n",
                pt->time_s,
                pt->time_s / 60.0,
                pt->time_s / 3600.0,
                pt->level_m, pt->level_m * 100.0f,
                pt->positive_rise_m, pt->positive_rise_m * 100.0f,
                pt->peak_positive_m, pt->peak_positive_m * 100.0f);
    }
    fclose(fp);
    return 1;
}

static int write_time_series_svg_for_recorder(const char *path, const StationSeriesRecorder *rec) {
    if (!rec || rec->station_i < 0 || rec->count == 0) return 0;
    FILE *fp = fopen(path, "wb");
    if (!fp) return 0;

    int gx = 0, gy = 0, sx = 0, sy = 0, region_id = 0, arrival_s = 0;
    float live_raw = 0.0f, live_pos = 0.0f, peak_pos = 0.0f;
    get_station_info(rec->station_i, &gx, &gy, &sx, &sy, &region_id,
                     &live_raw, &live_pos, &peak_pos, &arrival_s);

    double t0 = rec->points[0].time_s / 60.0;
    double t1 = rec->points[rec->count - 1].time_s / 60.0;
    if (t1 <= t0) t1 = t0 + 1.0;

    double ymin = 0.0, ymax = 0.0;
    for (size_t i = 0; i < rec->count; ++i) {
        double v = rec->points[i].level_m;
        if (i == 0) { ymin = v; ymax = v; }
        if (v < ymin) ymin = v;
        if (v > ymax) ymax = v;
        if ((double)rec->points[i].peak_positive_m > ymax) ymax = rec->points[i].peak_positive_m;
    }
    if (ymin > 0.0) ymin = 0.0;
    if (ymax < 0.0) ymax = 0.0;
    double pad = (ymax - ymin) * 0.12;
    if (pad < 0.05) pad = 0.05;
    ymin -= pad;
    ymax += pad;
    if (ymax <= ymin) { ymin = -0.1; ymax = 0.1; }

    const int W = 980, H = 560;
    const int L = 92, R = 34, T = 58, B = 74;
    const int PW = W - L - R;
    const int PH = H - T - B;

    fprintf(fp, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
    fprintf(fp, "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"%d\" height=\"%d\" viewBox=\"0 0 %d %d\">\n", W, H, W, H);
    fprintf(fp, "<rect width=\"100%%\" height=\"100%%\" fill=\"#ffffff\"/>\n");
    fprintf(fp, "<style>text{font-family:'Meiryo UI','Yu Gothic UI','Malgun Gothic',sans-serif;font-size:14px;fill:#222}.small{font-size:12px;fill:#555}.title{font-size:20px;font-weight:700}.axis{stroke:#222;stroke-width:1.2}.grid{stroke:#ddd;stroke-width:1}.zero{stroke:#777;stroke-width:1.2;stroke-dasharray:5 4}.level{fill:none;stroke:#d7372f;stroke-width:2}.peak{fill:none;stroke:#444;stroke-width:1.7;stroke-dasharray:6 5}</style>\n");
    fprintf(fp, "<text x=\"%d\" y=\"30\" class=\"title\">Station #%d sea-level time series</text>\n", L, rec->station_i + 1);
    fprintf(fp, "<text x=\"%d\" y=\"50\" class=\"small\">station_id %d: ", L, region_id);
    svg_write_escaped(fp, forecast_zone_name_from_id(region_id));
    fprintf(fp, " | grid %d,%d sample %d,%d | samples %zu</text>\n", gx, gy, sx, sy, rec->count);

    for (int k = 0; k <= 5; ++k) {
        double yy = ymin + (ymax - ymin) * (double)k / 5.0;
        int py = T + PH - (int)((yy - ymin) * PH / (ymax - ymin));
        fprintf(fp, "<line x1=\"%d\" y1=\"%d\" x2=\"%d\" y2=\"%d\" class=\"grid\"/>\n", L, py, L + PW, py);
        fprintf(fp, "<text x=\"10\" y=\"%d\" class=\"small\">%.2f m</text>\n", py + 4, yy);
    }
    for (int k = 0; k <= 6; ++k) {
        double tt = t0 + (t1 - t0) * (double)k / 6.0;
        int px = L + (int)((tt - t0) * PW / (t1 - t0));
        fprintf(fp, "<line x1=\"%d\" y1=\"%d\" x2=\"%d\" y2=\"%d\" class=\"grid\"/>\n", px, T, px, T + PH);
        fprintf(fp, "<text x=\"%d\" y=\"%d\" text-anchor=\"middle\" class=\"small\">%.1f</text>\n", px, T + PH + 22, tt);
    }
    if (ymin < 0.0 && ymax > 0.0) {
        int zy = T + PH - (int)((0.0 - ymin) * PH / (ymax - ymin));
        fprintf(fp, "<line x1=\"%d\" y1=\"%d\" x2=\"%d\" y2=\"%d\" class=\"zero\"/>\n", L, zy, L + PW, zy);
    }
    fprintf(fp, "<line x1=\"%d\" y1=\"%d\" x2=\"%d\" y2=\"%d\" class=\"axis\"/>\n", L, T, L, T + PH);
    fprintf(fp, "<line x1=\"%d\" y1=\"%d\" x2=\"%d\" y2=\"%d\" class=\"axis\"/>\n", L, T + PH, L + PW, T + PH);

    fprintf(fp, "<polyline class=\"level\" points=\"");
    for (size_t i = 0; i < rec->count; ++i) {
        double tx = rec->points[i].time_s / 60.0;
        double yy = rec->points[i].level_m;
        int px = L + (int)((tx - t0) * PW / (t1 - t0));
        int py = T + PH - (int)((yy - ymin) * PH / (ymax - ymin));
        fprintf(fp, "%d,%d ", px, py);
    }
    fprintf(fp, "\"/>\n");

    fprintf(fp, "<polyline class=\"peak\" points=\"");
    for (size_t i = 0; i < rec->count; ++i) {
        double tx = rec->points[i].time_s / 60.0;
        double yy = rec->points[i].peak_positive_m;
        int px = L + (int)((tx - t0) * PW / (t1 - t0));
        int py = T + PH - (int)((yy - ymin) * PH / (ymax - ymin));
        fprintf(fp, "%d,%d ", px, py);
    }
    fprintf(fp, "\"/>\n");

    fprintf(fp, "<text x=\"%d\" y=\"%d\" text-anchor=\"middle\">time after simulation start (min)</text>\n", L + PW / 2, H - 24);
    fprintf(fp, "<text x=\"22\" y=\"%d\" transform=\"rotate(-90 22,%d)\">water level change (m)</text>\n", T + PH / 2, T + PH / 2);
    fprintf(fp, "<rect x=\"%d\" y=\"%d\" width=\"16\" height=\"3\" fill=\"#d7372f\"/><text x=\"%d\" y=\"%d\" class=\"small\">live signed level</text>\n", L + 10, T + 14, L + 32, T + 18);
    fprintf(fp, "<line x1=\"%d\" y1=\"%d\" x2=\"%d\" y2=\"%d\" stroke=\"#444\" stroke-width=\"2\" stroke-dasharray=\"6 5\"/><text x=\"%d\" y=\"%d\" class=\"small\">peak positive rise</text>\n", L + 190, T + 15, L + 206, T + 15, L + 214, T + 18);
    fprintf(fp, "</svg>\n");
    fclose(fp);
    return 1;
}

static int write_time_series_csv(const char *path) {
    StationSeriesRecorder *rec = time_series_find_recorder(g_ts_station_i);
    return write_time_series_csv_for_recorder(path, rec);
}

static int write_time_series_svg(const char *path) {
    StationSeriesRecorder *rec = time_series_find_recorder(g_ts_station_i);
    return write_time_series_svg_for_recorder(path, rec);
}

static void make_station_timeseries_path(const char *base_path, int station_no, const char *extra_suffix, const char *ext, char *out, size_t out_size) {
    if (!out || out_size == 0) return;
    const char *dot = strrchr(base_path, '.');
    const char *slash1 = strrchr(base_path, '\\');
    const char *slash2 = strrchr(base_path, '/');
    const char *slash = slash1 > slash2 ? slash1 : slash2;
    if (dot && slash && dot < slash) dot = NULL;
    if (dot) {
        size_t prefix = (size_t)(dot - base_path);
        if (prefix >= out_size) prefix = out_size - 1;
        memcpy(out, base_path, prefix);
        out[prefix] = 0;
        snprintf(out + prefix, out_size - prefix, "_station_%04d%s%s", station_no, extra_suffix ? extra_suffix : "", ext ? ext : "");
    } else {
        snprintf(out, out_size, "%s_station_%04d%s%s", base_path, station_no, extra_suffix ? extra_suffix : "", ext ? ext : "");
    }
}

static void save_time_series_dialog(HWND hwnd) {
    if (g_ts_rec_count == 0) {
        set_status("No time-series station selected. Hover a station marker and press T first. Use Shift+T to add more.");
        MessageBeep(MB_ICONWARNING);
        return;
    }
    if (time_series_total_samples() == 0) time_series_record_current();

    char file_name[MAX_PATH];
    if (g_ts_rec_count == 1) {
        snprintf(file_name, sizeof(file_name), "station_%04d_timeseries.csv", g_ts_recorders[0].station_i + 1);
    } else {
        snprintf(file_name, sizeof(file_name), "selected_stations_timeseries.csv");
    }

    OPENFILENAMEA ofn;
    memset(&ofn, 0, sizeof(ofn));
    ofn.lStructSize = sizeof(ofn);
    ofn.hwndOwner = hwnd;
    ofn.lpstrFilter = "CSV files\0*.csv\0All files\0*.*\0";
    ofn.lpstrFile = file_name;
    ofn.nMaxFile = MAX_PATH;
    ofn.Flags = OFN_OVERWRITEPROMPT | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR;
    ofn.lpstrTitle = (g_ts_rec_count == 1)
        ? "Save selected station time series CSV + SVG graph"
        : "Save multi-station time series set";

    if (GetSaveFileNameA(&ofn)) {
        if (g_ts_rec_count == 1) {
            char graph_file[MAX_PATH];
            make_suffixed_path(file_name, "_graph", graph_file, sizeof(graph_file));
            char *graph_dot = strrchr(graph_file, '.');
            if (graph_dot) {
                snprintf(graph_dot, (size_t)(graph_file + sizeof(graph_file) - graph_dot), ".svg");
            } else {
                size_t graph_len = strlen(graph_file);
                if (graph_len + 4u < sizeof(graph_file)) strcat(graph_file, ".svg");
            }
            int ok_csv = write_time_series_csv_for_recorder(file_name, &g_ts_recorders[0]);
            int ok_svg = write_time_series_svg_for_recorder(graph_file, &g_ts_recorders[0]);
            if (ok_csv && ok_svg) {
                set_status("Saved time series CSV and SVG graph: %s", graph_file);
            } else if (ok_csv) {
                set_status("Saved time series CSV, but SVG graph failed.");
            } else {
                set_status("Failed to save selected station time series.");
                MessageBeep(MB_ICONERROR);
            }
        } else {
            int saved = 0;
            int failed = 0;
            for (size_t i = 0; i < g_ts_rec_count; ++i) {
                char csv_file[MAX_PATH];
                char svg_file[MAX_PATH];
                int station_no = g_ts_recorders[i].station_i + 1;
                make_station_timeseries_path(file_name, station_no, "", ".csv", csv_file, sizeof(csv_file));
                make_station_timeseries_path(file_name, station_no, "_graph", ".svg", svg_file, sizeof(svg_file));
                int ok_csv = write_time_series_csv_for_recorder(csv_file, &g_ts_recorders[i]);
                int ok_svg = write_time_series_svg_for_recorder(svg_file, &g_ts_recorders[i]);
                if (ok_csv && ok_svg) saved++;
                else failed++;
            }
            if (failed == 0) {
                set_status("Saved %d station time-series CSV+SVG pairs using base name: %s", saved, file_name);
            } else {
                set_status("Saved %d station time-series pair(s), failed %d.", saved, failed);
                MessageBeep(MB_ICONWARNING);
            }
        }
        InvalidateRect(hwnd, NULL, FALSE);
    }
}

static int find_source_cell(int grid_x, int grid_y) {
    for (size_t i = 0; i < g_sources.count; ++i) {
        if (g_sources.items[i].grid_x == grid_x && g_sources.items[i].grid_y == grid_y) {
            return (int)i;
        }
    }
    return -1;
}

static int set_source_cell_footprint_only(int grid_x, int grid_y, int delay) {
    /*
       Register earthquake source area for M0/Mw, but do not inject tsunami
       height into the water solver.  Used for inland/land cells, coast-guard
       cells, and depth-factor-zero sources.
    */
    if (grid_x < 0 || grid_y < 0 || grid_x >= (int)g_grid.width || grid_y >= (int)g_grid.height) return 0;

    size_t idx = tsunami_index(&g_grid, grid_x, grid_y);
    int source_i = find_source_cell(grid_x, grid_y);
    float new_slip = source_cell_fallback_slip_m(0.0f);

    if (source_i >= 0) {
        g_sources.items[source_i].slip_m =
            sanitize_source_slip_for_height(g_sources.items[source_i].height_m,
                blend_source_slip_metric_m(g_sources.items[source_i].slip_m, new_slip, 0));
        g_sources.items[source_i].delay_s = combine_source_delay_s(g_sources.items[source_i].delay_s, delay);

        /*
           If this cell already had water deformation, preserve it.  Footprint-only
           samples should never erase the distributed uplift field.
        */
        if (!g_grid.solid[idx]) {
            g_sim.source_height_m[idx] = g_sources.items[source_i].height_m;
            g_sim.source_delay_s[idx] = g_sources.items[source_i].delay_s;
        }
        return 1;
    }

    source_list_append(&g_sources, grid_x, grid_y, 0.0f, delay);
    source_set_last_slip_m(&g_sources, new_slip);

    g_sim.source_height_m[idx] = 0.0f;
    g_sim.source_delay_s[idx] = delay;
    if (g_sim.source_applied_m) g_sim.source_applied_m[idx] = 0.0f;
    return 1;
}

static void clear_source_items_from_index(size_t start_index) {
    if (start_index >= g_sources.count) return;

    for (size_t i = start_index; i < g_sources.count; ++i) {
        int gx = g_sources.items[i].grid_x;
        int gy = g_sources.items[i].grid_y;
        if (gx >= 0 && gy >= 0 && gx < (int)g_grid.width && gy < (int)g_grid.height) {
            size_t idx = tsunami_index(&g_grid, (uint32_t)gx, (uint32_t)gy);
            g_sim.source_height_m[idx] = 0.0f;
            g_sim.source_delay_s[idx] = 0;
            if (g_sim.source_applied_m) g_sim.source_applied_m[idx] = 0.0f;
        }
    }

    g_sources.count = start_index;
}


static int source_index_is_valid(int source_i) {
    return source_i >= 0 && (size_t)source_i < g_sources.count;
}

static int node_tune_ensure_selection_capacity(void) {
    size_t needed = g_sources.capacity;
    if (needed < g_sources.count) needed = g_sources.count;
    if (needed == 0) needed = 1;
    if (g_node_tune_selected_capacity >= needed && g_node_tune_selected) return 1;
    uint8_t *new_flags = (uint8_t *)realloc(g_node_tune_selected, needed * sizeof(uint8_t));
    if (!new_flags) {
        MessageBeep(MB_ICONERROR);
        set_status("Node tune: selection memory allocation failed.");
        return 0;
    }
    if (needed > g_node_tune_selected_capacity) {
        memset(new_flags + g_node_tune_selected_capacity, 0, needed - g_node_tune_selected_capacity);
    }
    g_node_tune_selected = new_flags;
    g_node_tune_selected_capacity = needed;
    return 1;
}

static void node_tune_sync_selected_bounds(void) {
    if (!node_tune_ensure_selection_capacity()) {
        g_selected_source_i = -1;
        return;
    }
    if (!source_index_is_valid(g_selected_source_i)) g_selected_source_i = -1;
    for (size_t i = g_sources.count; i < g_node_tune_selected_capacity; ++i) {
        g_node_tune_selected[i] = 0u;
    }
}

static int node_tune_is_selected(int source_i) {
    if (!source_index_is_valid(source_i)) return 0;
    if (!g_node_tune_selected || (size_t)source_i >= g_node_tune_selected_capacity) return 0;
    return g_node_tune_selected[source_i] != 0u;
}

static size_t node_tune_selected_count(void) {
    node_tune_sync_selected_bounds();
    size_t n = 0;
    for (size_t i = 0; i < g_sources.count; ++i) {
        if (g_node_tune_selected && g_node_tune_selected[i]) ++n;
    }
    return n;
}

static void node_tune_clear_selection(void) {
    if (!node_tune_ensure_selection_capacity()) return;
    if (g_node_tune_selected_capacity > 0) memset(g_node_tune_selected, 0, g_node_tune_selected_capacity);
    g_selected_source_i = -1;
}

static void node_tune_select_only(int source_i) {
    if (!node_tune_ensure_selection_capacity()) return;
    if (!source_index_is_valid(source_i)) {
        node_tune_clear_selection();
        return;
    }
    memset(g_node_tune_selected, 0, g_node_tune_selected_capacity);
    g_node_tune_selected[source_i] = 1u;
    g_selected_source_i = source_i;
}

static void node_tune_add_to_selection(int source_i) {
    if (!node_tune_ensure_selection_capacity()) return;
    if (!source_index_is_valid(source_i)) return;
    g_node_tune_selected[source_i] = 1u;
    g_selected_source_i = source_i;
}

static void node_tune_toggle_selection(int source_i) {
    if (!node_tune_ensure_selection_capacity()) return;
    if (!source_index_is_valid(source_i)) return;
    g_node_tune_selected[source_i] = g_node_tune_selected[source_i] ? 0u : 1u;
    if (g_node_tune_selected[source_i]) {
        g_selected_source_i = source_i;
        return;
    }
    if (g_selected_source_i == source_i) g_selected_source_i = -1;
    for (size_t i = 0; i < g_sources.count; ++i) {
        if (g_node_tune_selected[i]) {
            g_selected_source_i = (int)i;
            return;
        }
    }
}

static void node_tune_select_all(void) {
    if (!node_tune_ensure_selection_capacity()) return;
    if (g_sources.count == 0) {
        g_selected_source_i = -1;
        set_status("Node tune: there are no source nodes to select.");
        return;
    }
    for (size_t i = 0; i < g_sources.count; ++i) g_node_tune_selected[i] = 1u;
    g_selected_source_i = 0;
    set_status("Node tune: selected all %zu source nodes. Edits now apply to every selected node.", g_sources.count);
}

static void node_tune_select_current_if_empty(void) {
    if (node_tune_selected_count() == 0 && source_index_is_valid(g_selected_source_i)) {
        node_tune_add_to_selection(g_selected_source_i);
    }
}

static int node_tune_for_each_selected(void (*fn)(SourceSpec *src, void *ctx), void *ctx) {
    node_tune_sync_selected_bounds();
    node_tune_select_current_if_empty();
    size_t n = 0;
    for (size_t i = 0; i < g_sources.count; ++i) {
        if (g_node_tune_selected && g_node_tune_selected[i]) {
            fn(&g_sources.items[i], ctx);
            ++n;
        }
    }
    return (int)n;
}

static int find_source_marker_at_screen(int sx, int sy) {
    int hover_r = scaled_marker_half_size_px(8);
    if (hover_r < 8) hover_r = 8;
    int best_i = -1;
    int best_d2 = hover_r * hover_r;
    for (size_t i = 0; i < g_sources.count; ++i) {
        int cx = grid_cell_screen_center_x(g_sources.items[i].grid_x);
        int cy = grid_cell_screen_center_y(g_sources.items[i].grid_y);
        int dx = sx - cx;
        int dy = sy - cy;
        int d2 = dx * dx + dy * dy;
        if (d2 <= best_d2) {
            best_d2 = d2;
            best_i = (int)i;
        }
    }
    return best_i;
}

static void node_tune_reapply_sources(void) {
    /* Fine-tuning is a pre-run editing tool.  Rebuild the source field so
       source_height/source_delay/source_applied stay consistent. */
    reset_state_preserve_sources();
}

static void node_tune_show_selected_status(const char *prefix) {
    node_tune_sync_selected_bounds();
    size_t selected_n = node_tune_selected_count();
    if (selected_n == 0 || !source_index_is_valid(g_selected_source_i)) {
        set_status("Node tune: no source node selected. Click a source marker, or Shift-drag a box. Ctrl-click toggles, Shift-click adds, A selects all, C clears.");
        return;
    }
    SourceSpec *src = &g_sources.items[g_selected_source_i];
    if (selected_n > 1) {
        set_status("%s %zu nodes selected. Primary #%d/%zu x=%d y=%d h=%.3f slip=%.3f delay=%ds dip@paint=%.0f. Edits apply to selected; Del removes.",
                   prefix ? prefix : "Node tune:",
                   selected_n, g_selected_source_i + 1, g_sources.count,
                   src->grid_x, src->grid_y, src->height_m, src->slip_m, src->delay_s,
                   source_node_stamp_dip_deg(src));
    } else {
        set_status("%s node #%d/%zu x=%d y=%d height=%.3f m slip=%.3f m delay=%ds dip@paint=%.0f. Ctrl/Shift-click or Shift-drag for multi-select, A all, C clear.",
                   prefix ? prefix : "Node tune:",
                   g_selected_source_i + 1, g_sources.count,
                   src->grid_x, src->grid_y, src->height_m, src->slip_m, src->delay_s,
                   source_node_stamp_dip_deg(src));
    }
}

static int node_tune_select_source_at_screen(int sx, int sy) {
    int source_i = find_source_marker_at_screen(sx, sy);
    int ctrl = (GetKeyState(VK_CONTROL) & 0x8000) != 0;
    int shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0;
    if (source_i >= 0) {
        if (ctrl) {
            node_tune_toggle_selection(source_i);
            node_tune_show_selected_status(node_tune_is_selected(source_i) ? "Toggled on" : "Toggled off");
        } else if (shift) {
            node_tune_add_to_selection(source_i);
            node_tune_show_selected_status("Added");
        } else {
            node_tune_select_only(source_i);
            node_tune_show_selected_status("Selected");
        }
        return 1;
    }
    if (!ctrl && !shift) node_tune_clear_selection();
    set_status("Node tune: no source marker here. Click a marker; Ctrl-click toggles, Shift-click adds.");
    return 0;
}

static void node_tune_box_select_begin(int sx, int sy) {
    g_node_tune_box_select_active = 1;
    g_node_tune_box_start_x = sx;
    g_node_tune_box_start_y = sy;
    g_node_tune_box_cur_x = sx;
    g_node_tune_box_cur_y = sy;
    set_status("Node tune box select: drag while holding Shift to add all source nodes inside the rectangle.");
}

static void node_tune_box_select_update(int sx, int sy) {
    if (!g_node_tune_box_select_active) return;
    g_node_tune_box_cur_x = sx;
    g_node_tune_box_cur_y = sy;
}

static int node_tune_box_select_finish(void) {
    if (!g_node_tune_box_select_active) return 0;
    g_node_tune_box_select_active = 0;

    int x0 = g_node_tune_box_start_x;
    int y0 = g_node_tune_box_start_y;
    int x1 = g_node_tune_box_cur_x;
    int y1 = g_node_tune_box_cur_y;
    int dx = x1 - x0;
    int dy = y1 - y0;
    int drag_d2 = dx * dx + dy * dy;

    /* A tiny Shift click keeps the existing single-node add behaviour. */
    if (drag_d2 < 25) {
        int source_i = find_source_marker_at_screen(x1, y1);
        if (source_i >= 0) {
            node_tune_add_to_selection(source_i);
            node_tune_show_selected_status("Added");
            return 1;
        }
        set_status("Node tune box select: no source marker under Shift-click.");
        return 0;
    }

    if (x0 > x1) { int t = x0; x0 = x1; x1 = t; }
    if (y0 > y1) { int t = y0; y0 = y1; y1 = t; }

    if (!node_tune_ensure_selection_capacity()) return 0;
    int added = 0;
    int inside = 0;
    int first_added = -1;
    for (size_t i = 0; i < g_sources.count; ++i) {
        int sx = grid_cell_screen_center_x(g_sources.items[i].grid_x);
        int sy = grid_cell_screen_center_y(g_sources.items[i].grid_y);
        if (sx < x0 || sx > x1 || sy < y0 || sy > y1) continue;
        ++inside;
        if (!g_node_tune_selected[i]) {
            g_node_tune_selected[i] = 1u;
            ++added;
            if (first_added < 0) first_added = (int)i;
        }
    }
    if (first_added >= 0) g_selected_source_i = first_added;
    else if (inside > 0 && !source_index_is_valid(g_selected_source_i)) {
        for (size_t i = 0; i < g_sources.count; ++i) {
            if (g_node_tune_selected[i]) { g_selected_source_i = (int)i; break; }
        }
    }

    size_t total = node_tune_selected_count();
    set_status("Node tune box select: rectangle contained %d node(s), added %d new node(s). Total selected: %zu.",
               inside, added, total);
    return inside > 0;
}

typedef struct { float delta_m; } NodeTuneFloatDelta;
typedef struct { int delta_s; } NodeTuneIntDelta;

static float node_tune_auto_surface_height_from_slip(const SourceSpec *src, float new_slip_m, float old_slip_m) {
    /*
       Advanced-node tuning now treats slip as the primary value, like normal
       drawing.  The water uplift/subsidence height is recalculated
       automatically instead of being a separate manual number.

       For nodes produced by the finite-fault brush, the existing height already
       contains the local lobe weight/sign/taper.  Scaling by slip ratio keeps
       that shape intact.  Footprint-only nodes with height=0 stay footprint-only.
    */
    if (!src) return 0.0f;
    if (new_slip_m < 0.0f) new_slip_m = 0.0f;
    if (new_slip_m <= 0.000001f) return 0.0f;

    if (old_slip_m > 0.000001f) {
        float h = src->height_m * (new_slip_m / old_slip_m);
        if (fabsf(h) < 0.0005f) h = 0.0f;
        return h;
    }

    if (fabsf(src->height_m) < 0.0005f) {
        return 0.0f;
    }

    {
        float sign = src->height_m < 0.0f ? -1.0f : 1.0f;
        float dip_rad = source_node_stamp_dip_deg(src) * TSUNAMI_PI / 180.0f;
        float reverse_mix = clampf_gui(1.0f - g_strike_slip_mix, 0.0f, 1.0f);
        float strike_mix = clampf_gui(g_strike_slip_mix, 0.0f, 1.0f);
        float reverse_h = new_slip_m * sinf(dip_rad) * reverse_mix * current_tsunami_depth_factor();
        float strike_h = new_slip_m * strike_mix * strike_slip_water_factor() * current_strike_slip_depth_factor();
        float h = sign * (reverse_h + strike_h);
        if (fabsf(h) < 0.0005f) h = 0.0f;
        return h;
    }
}

static void node_tune_set_slip_auto_height(SourceSpec *src, float new_slip_m) {
    if (!src) return;
    if (source_height_is_subsidence(src->height_m)) {
        src->slip_m = 0.0f;
        return;
    }
    if (new_slip_m < 0.0f) new_slip_m = 0.0f;
    {
        float old_slip_m = src->slip_m;
        if (old_slip_m < 0.0f) old_slip_m = -old_slip_m;
        src->height_m = node_tune_auto_surface_height_from_slip(src, new_slip_m, old_slip_m);
        src->slip_m = sanitize_source_slip_for_height(src->height_m, new_slip_m);
    }
}

static void node_tune_adjust_slip_or_subsidence_amplitude(SourceSpec *src, float delta_m) {
    if (!src) return;

    if (source_height_is_subsidence(src->height_m)) {
        /* Subsidence nodes have no independent fault slip.  In advanced mode,
           +/- still lets the user fine-tune the depression amplitude, but the
           node's slip_m remains exactly zero. */
        float amp = -src->height_m;
        amp += delta_m;
        if (amp < 0.0f) amp = 0.0f;
        src->height_m = (amp > SOURCE_SLIP_HEIGHT_EPS_M) ? -amp : 0.0f;
        src->slip_m = 0.0f;
        return;
    }

    float base_slip = src->slip_m;
    if (base_slip < 0.0f) base_slip = -base_slip;
    if (base_slip <= 0.000001f && src->height_m > SOURCE_SLIP_HEIGHT_EPS_M) {
        base_slip = src->height_m;
    }
    node_tune_set_slip_auto_height(src, base_slip + delta_m);
}

static void node_tune_apply_height(SourceSpec *src, void *ctx) {
    /* +/- in advanced mode changes uplift slip; subsidence nodes keep slip=0. */
    float delta_m = ((NodeTuneFloatDelta *)ctx)->delta_m;
    node_tune_adjust_slip_or_subsidence_amplitude(src, delta_m);
}

static void node_tune_apply_delay(SourceSpec *src, void *ctx) {
    int delta_s = ((NodeTuneIntDelta *)ctx)->delta_s;
    src->delay_s += delta_s;
    if (src->delay_s < 0) src->delay_s = 0;
}

static void node_tune_apply_slip(SourceSpec *src, void *ctx) {
    float delta_m = ((NodeTuneFloatDelta *)ctx)->delta_m;
    node_tune_adjust_slip_or_subsidence_amplitude(src, delta_m);
}

static void node_tune_apply_zero_height(SourceSpec *src, void *ctx) {
    (void)ctx;
    if (src->height_m > SOURCE_SLIP_HEIGHT_EPS_M && src->slip_m < src->height_m) {
        src->slip_m = src->height_m;
    }
    if (source_height_is_subsidence(src->height_m)) {
        src->slip_m = 0.0f;
    }
    src->height_m = 0.0f;
    src->slip_m = sanitize_source_slip_for_height(src->height_m, src->slip_m);
}

static void node_tune_apply_template(SourceSpec *src, void *ctx) {
    (void)ctx;
    src->height_m = current_equivalent_peak_surface_m();
    src->slip_m = sanitize_source_slip_for_height(src->height_m, current_source_metric_slip_m());
    source_apply_current_fault_metadata(src);
    src->delay_s = g_new_source_delay_s;
}

static void node_tune_adjust_height(float delta_m) {
    NodeTuneFloatDelta ctx = { delta_m };
    source_undo_push("node tune slip/amplitude");
    int n = node_tune_for_each_selected(node_tune_apply_height, &ctx);
    if (n <= 0) {
        node_tune_show_selected_status(NULL);
        return;
    }
    node_tune_reapply_sources();
    set_status("Node tune: %+.3f m applied to %d selected node(s); uplift uses slip, subsidence keeps slip=0.", delta_m, n);
}

static void node_tune_adjust_delay(int delta_s) {
    NodeTuneIntDelta ctx = { delta_s };
    source_undo_push("node tune delay");
    int n = node_tune_for_each_selected(node_tune_apply_delay, &ctx);
    if (n <= 0) {
        node_tune_show_selected_status(NULL);
        return;
    }
    node_tune_reapply_sources();
    set_status("Node tune: delay %+d s applied to %d selected node(s).", delta_s, n);
}

static void node_tune_adjust_slip(float delta_m) {
    NodeTuneFloatDelta ctx = { delta_m };
    source_undo_push("node tune slip/amplitude");
    int n = node_tune_for_each_selected(node_tune_apply_slip, &ctx);
    if (n <= 0) {
        node_tune_show_selected_status(NULL);
        return;
    }
    node_tune_reapply_sources();
    set_status("Node tune: %+.3f m applied to %d selected node(s); uplift uses slip, subsidence keeps slip=0.", delta_m, n);
}

static void node_tune_zero_height_keep_slip(void) {
    source_undo_push("node tune height zero");
    int n = node_tune_for_each_selected(node_tune_apply_zero_height, NULL);
    if (n <= 0) {
        node_tune_show_selected_status(NULL);
        return;
    }
    node_tune_reapply_sources();
    set_status("Node tune: set height=0 for %d selected node(s), slip kept.", n);
}

static void node_tune_set_to_current_template(void) {
    source_undo_push("node tune copy template");
    int n = node_tune_for_each_selected(node_tune_apply_template, NULL);
    if (n <= 0) {
        node_tune_show_selected_status(NULL);
        return;
    }
    node_tune_reapply_sources();
    set_status("Node tune: copied current brush values to %d selected node(s).", n);
}

static void node_tune_move_selected(int dx, int dy) {
    node_tune_sync_selected_bounds();
    node_tune_select_current_if_empty();
    size_t selected_n = node_tune_selected_count();
    if (selected_n == 0) {
        node_tune_show_selected_status(NULL);
        return;
    }
    for (size_t i = 0; i < g_sources.count; ++i) {
        if (!node_tune_is_selected((int)i)) continue;
        SourceSpec *src = &g_sources.items[i];
        int nx = src->grid_x + dx;
        int ny = src->grid_y + dy;
        if (nx < 0 || ny < 0 || nx >= (int)g_grid.width || ny >= (int)g_grid.height) {
            set_status("Node tune: group move blocked by map edge at node #%zu.", i + 1u);
            MessageBeep(MB_ICONWARNING);
            return;
        }
        int existing = find_source_cell(nx, ny);
        if (existing >= 0 && !node_tune_is_selected(existing)) {
            set_status("Node tune: target x=%d y=%d already has unselected source node #%d.", nx, ny, existing + 1);
            MessageBeep(MB_ICONWARNING);
            return;
        }
    }
    source_undo_push("node tune move");
    for (size_t i = 0; i < g_sources.count; ++i) {
        if (!node_tune_is_selected((int)i)) continue;
        g_sources.items[i].grid_x += dx;
        g_sources.items[i].grid_y += dy;
    }
    node_tune_reapply_sources();
    set_status("Node tune: moved %zu selected node(s) by dx=%d dy=%d.", selected_n, dx, dy);
}

static void node_tune_remove_selected(void) {
    node_tune_sync_selected_bounds();
    node_tune_select_current_if_empty();
    size_t selected_n = node_tune_selected_count();
    if (selected_n == 0) {
        node_tune_show_selected_status(NULL);
        return;
    }
    source_undo_push("node tune delete");
    for (int i = (int)g_sources.count - 1; i >= 0; --i) {
        if (!node_tune_is_selected(i)) continue;
        if ((size_t)i + 1u < g_sources.count) {
            memmove(&g_sources.items[i],
                    &g_sources.items[i + 1],
                    (g_sources.count - (size_t)i - 1u) * sizeof(SourceSpec));
            memmove(&g_node_tune_selected[i],
                    &g_node_tune_selected[i + 1],
                    (g_sources.count - (size_t)i - 1u) * sizeof(uint8_t));
        }
        g_sources.count--;
        if (g_node_tune_selected && (size_t)i < g_node_tune_selected_capacity) g_node_tune_selected[g_sources.count] = 0u;
    }
    g_selected_source_i = (g_sources.count > 0) ? 0 : -1;
    node_tune_clear_selection();
    node_tune_reapply_sources();
    set_status("Node tune: removed %zu selected node(s). Remaining source nodes: %zu.", selected_n, g_sources.count);
}

static void node_tune_select_relative(int step) {
    if (g_sources.count == 0) {
        node_tune_clear_selection();
        set_status("Node tune: there are no source nodes to select.");
        return;
    }
    size_t selected_n = node_tune_selected_count();
    int n = (int)g_sources.count;
    if (selected_n > 1 && source_index_is_valid(g_selected_source_i)) {
        int cur = g_selected_source_i;
        for (int k = 1; k <= n; ++k) {
            int ni = (cur + step * k) % n;
            if (ni < 0) ni += n;
            if (node_tune_is_selected(ni)) {
                g_selected_source_i = ni;
                node_tune_show_selected_status("Primary changed");
                return;
            }
        }
    }
    int next_i;
    if (!source_index_is_valid(g_selected_source_i)) next_i = (step >= 0) ? 0 : (int)g_sources.count - 1;
    else {
        next_i = (g_selected_source_i + step) % n;
        if (next_i < 0) next_i += n;
    }
    node_tune_select_only(next_i);
    node_tune_show_selected_status("Selected");
}

static int node_tune_handle_key(HWND hwnd, WPARAM key) {
    (void)hwnd;
    if (!g_node_tune_mode) return 0;
    if (key == 'E') {
        g_node_tune_box_select_active = 0;
        g_node_tune_mode = 0;
        set_status("Advanced node tune mode: off. Normal source drawing restored.");
        return 1;
    }
    if (key == VK_TAB) {
        node_tune_select_relative((GetKeyState(VK_SHIFT) & 0x8000) ? -1 : 1);
        return 1;
    }
    if (key == VK_ESCAPE || key == 'C') {
        node_tune_clear_selection();
        set_status("Node tune: selection cleared. Click a marker to select again.");
        return 1;
    }
    if (key == 'A') {
        node_tune_select_all();
        return 1;
    }
    if (key == VK_DELETE || key == VK_BACK) {
        node_tune_remove_selected();
        return 1;
    }

    float h_step = 0.10f;
    if (GetKeyState(VK_SHIFT) & 0x8000) h_step = 0.50f;
    if (GetKeyState(VK_CONTROL) & 0x8000) h_step = 0.01f;

    int delay_step = g_dt_s;
    if (delay_step <= 0) delay_step = 30;
    if (GetKeyState(VK_SHIFT) & 0x8000) delay_step = 300;
    if (GetKeyState(VK_CONTROL) & 0x8000) delay_step = 1;

    int move_step = (GetKeyState(VK_SHIFT) & 0x8000) ? 5 : 1;

    switch (key) {
    case VK_OEM_PLUS:
    case '=':
        node_tune_adjust_height(h_step);
        return 1;
    case VK_OEM_MINUS:
    case '-':
        node_tune_adjust_height(-h_step);
        return 1;
    case VK_OEM_4:
        node_tune_adjust_delay(-delay_step);
        return 1;
    case VK_OEM_6:
        node_tune_adjust_delay(delay_step);
        return 1;
    case VK_PRIOR:
        node_tune_adjust_slip(h_step);
        return 1;
    case VK_NEXT:
        node_tune_adjust_slip(-h_step);
        return 1;
    case VK_LEFT:
        node_tune_move_selected(-move_step, 0);
        return 1;
    case VK_RIGHT:
        node_tune_move_selected(move_step, 0);
        return 1;
    case VK_UP:
        node_tune_move_selected(0, -move_step);
        return 1;
    case VK_DOWN:
        node_tune_move_selected(0, move_step);
        return 1;
    case 'F':
        node_tune_zero_height_keep_slip();
        return 1;
    case 'U':
        node_tune_set_to_current_template();
        return 1;
    }
    return 0;
}

static int set_source_cell_height(HWND hwnd, int grid_x, int grid_y, float height_m, int delay, int quiet) {
    if (grid_x < 0 || grid_y < 0 || grid_x >= (int)g_grid.width || grid_y >= (int)g_grid.height) return 0;

    size_t idx = tsunami_index(&g_grid, grid_x, grid_y);

    if (g_grid.solid[idx]) {
        /*
           Land cells inside the negative subsidence lobe are not fault-plane
           footprint.  Older logic converted every land source cell into
           footprint-only, so land-side depression pixels got slip_m again and
           inflated M0/Mw.  Keep inland/land positive rupture footprint, but
           ignore land-only subsidence response.
        */
        if (source_height_is_subsidence(height_m)) return 0;

        /*
           Inland earthquakes such as the 2024 Noto/Ishikawa event or Median
           Tectonic Line scenarios still have seismic moment.  Count their area
           for Mw, but do not add initial sea-surface height on land.
        */
        return set_source_cell_footprint_only(grid_x, grid_y, delay);
    }

    if (fabsf(height_m) < 0.0005f) {
        return set_source_cell_footprint_only(grid_x, grid_y, delay);
    }

    int source_i = find_source_cell(grid_x, grid_y);
    float new_slip = source_cell_fallback_slip_m(height_m);

    if (source_i >= 0) {
        float old_h = g_sources.items[source_i].height_m;
        float blended_h = blend_source_height_m(old_h, height_m, 0);
        float blended_slip = blend_source_slip_metric_m(g_sources.items[source_i].slip_m, new_slip, 0);
        blended_slip = sanitize_source_slip_for_height(blended_h, blended_slip);
        int blended_delay = combine_source_delay_s(g_sources.items[source_i].delay_s, delay);

        if (fabsf(blended_h - old_h) >= 0.00005f ||
            fabsf(blended_slip - g_sources.items[source_i].slip_m) >= 0.0005f ||
            blended_delay != g_sources.items[source_i].delay_s) {
            g_sources.items[source_i].height_m = blended_h;
            g_sources.items[source_i].slip_m = blended_slip;
            g_sources.items[source_i].delay_s = blended_delay;
            g_sim.source_height_m[idx] = blended_h;
            g_sim.source_delay_s[idx] = blended_delay;
            if (g_sim.source_applied_m) g_sim.source_applied_m[idx] = 0.0f;
            return 1;
        }
        return 0;
    }

    source_list_append(&g_sources, grid_x, grid_y, height_m, delay);
    source_set_last_slip_m(&g_sources, new_slip);
    g_sim.source_height_m[idx] = height_m;
    g_sim.source_delay_s[idx] = delay;
    if (g_sim.source_applied_m) g_sim.source_applied_m[idx] = 0.0f;
    return 1;
}

static int set_source_cell_height_force(HWND hwnd, int grid_x, int grid_y, float height_m, int delay) {
    /*
       Force-update version used by long strike-slip stroke cleanup.
       This must be an actual replacement for the current finite-fault repaint,
       while still preserving uplift/footprint slip_m for moment calculation. Subsidence nodes keep slip_m=0.
    */
    (void)hwnd;
    if (grid_x < 0 || grid_y < 0 || grid_x >= (int)g_grid.width || grid_y >= (int)g_grid.height) return 0;

    size_t idx = tsunami_index(&g_grid, grid_x, grid_y);
    if (g_grid.solid[idx]) {
        /* Same rule as set_source_cell_height(): land-side subsidence is a
           water-deformation response, not rupture area. */
        if (source_height_is_subsidence(height_m)) return 0;
        return set_source_cell_footprint_only(grid_x, grid_y, delay);
    }

    if (fabsf(height_m) < 0.0005f) {
        return set_source_cell_footprint_only(grid_x, grid_y, delay);
    }

    float new_slip = source_cell_fallback_slip_m(height_m);
    int source_i = find_source_cell(grid_x, grid_y);

    if (source_i >= 0) {
        g_sources.items[source_i].height_m = height_m;
        g_sources.items[source_i].slip_m =
            sanitize_source_slip_for_height(height_m,
                blend_source_slip_metric_m(g_sources.items[source_i].slip_m, new_slip, 1));
        g_sources.items[source_i].delay_s = delay;
        g_sim.source_height_m[idx] = height_m;
        g_sim.source_delay_s[idx] = delay;
        if (g_sim.source_applied_m) g_sim.source_applied_m[idx] = 0.0f;
        return 1;
    }

    source_list_append(&g_sources, grid_x, grid_y, height_m, delay);
    source_set_last_slip_m(&g_sources, new_slip);
    g_sim.source_height_m[idx] = height_m;
    g_sim.source_delay_s[idx] = delay;
    if (g_sim.source_applied_m) g_sim.source_applied_m[idx] = 0.0f;
    return 1;
}

static int current_source_delay_for_new_paint(void) {
    int delay;
    if (g_active_paint_delay_override_s >= 0) {
        delay = g_active_paint_delay_override_s;
    } else {
        delay = rounded_source_delay_with_cursor(g_rupture_delay_cursor_s);
    }
    if (g_time_s >= delay) delay = g_time_s + 1;
    return delay;
}

static int paint_source_flat_disc(HWND hwnd, int center_x, int center_y) {
    int affected = 0;
    int r = fault_width_radius_cells_int_at_y((float)center_y);
    int delay = current_source_delay_for_new_paint();
    float eq_h = current_equivalent_peak_surface_m();
    for (int dy = -r; dy <= r; ++dy) {
        for (int dx = -r; dx <= r; ++dx) {
            if (dx * dx + dy * dy > r * r) continue;
            affected += set_source_cell_height(hwnd, center_x + dx, center_y + dy,
                                               eq_h, delay, 1);
        }
    }
    return affected;
}

static int paint_source_reverse_fault(HWND hwnd, int center_x, int center_y) {
    int affected = 0;
    int delay = current_source_delay_for_new_paint();

    float strike_x, strike_y, normal_x, normal_y;
    reverse_fault_basis(&strike_x, &strike_y, &normal_x, &normal_y);
    float ss_strike_x, ss_strike_y, ss_normal_x, ss_normal_y;
    strike_slip_fault_basis(&ss_strike_x, &ss_strike_y, &ss_normal_x, &ss_normal_y);

    int r = reverse_profile_radius_cells_at_y((float)center_y);
    float width_scale = 1.0f;
    float reverse_mix = clampf_gui(1.0f - g_strike_slip_mix, 0.0f, 1.0f);
    float strike_mix = clampf_gui(g_strike_slip_mix, 0.0f, 1.0f);

    /*
       Dip controls geometry, and the user input is now interpreted as slip.
       Smaller dip = broader across-dip deformation.  The reverse-fault uplift
       amplitude is approximated from the vertical component of slip.
    */
    float uplift_half_along = (float)clampi_gui((int)lroundf((float)r * 1.20f), 2, 60);
    float uplift_half_across = (float)clampi_gui((int)lroundf((float)r * 0.90f * width_scale), 2, 90);
    float subsidence_half_along = (float)clampi_gui((int)lroundf((float)r * 1.45f), 2, 70);
    float subsidence_half_across = (float)clampi_gui((int)lroundf((float)r * 1.15f * width_scale), 2, 100);
    float subsidence_offset = (float)clampi_gui((int)lroundf((float)r * 1.10f * sqrtf(width_scale)), 2, 65);

    /*
       Reverse/thrust elastic deformation is not a single perfect oval.
       Keep one dominant subsidence lobe on the selected down-dip/footwall side,
       but add weaker opposite-side and along-strike edge settlement to mimic
       finite-fault compensation and edge taper.  A real forecast should use
       Okada/dislocation deformation; this is a stable GUI approximation.
    */
    float sub_center_x = (float)center_x + normal_x * subsidence_offset;
    float sub_center_y = (float)center_y + normal_y * subsidence_offset;
    float weak_sub_center_x = (float)center_x - normal_x * (subsidence_offset * 0.78f);
    float weak_sub_center_y = (float)center_y - normal_y * (subsidence_offset * 0.78f);
    float edge_offset = uplift_half_along * 1.05f;
    float edge1_center_x = (float)center_x + strike_x * edge_offset;
    float edge1_center_y = (float)center_y + strike_y * edge_offset;
    float edge2_center_x = (float)center_x - strike_x * edge_offset;
    float edge2_center_y = (float)center_y - strike_y * edge_offset;
    float edge_sub_half_along = fmaxf((float)r * 0.70f, 2.0f);
    float edge_sub_half_across = fmaxf((float)r * 0.85f * width_scale, 2.0f);

    /*
       Strike-slip toy disturbance:
       A conservative two-lobe antisymmetric pattern along the strike.
       This does not claim to be an Okada horizontal-forcing solution; it only
       gives pure/mostly strike-slip scenarios a small, visible water disturbance.
    */
    float ss_half_along = (float)clampi_gui((int)lroundf((float)r * 2.20f), 3, 110);
    float ss_half_across = (float)clampi_gui((int)lroundf((float)r * 0.75f * width_scale), 2, 85);
    float reverse_peak_raw = current_reverse_peak_uplift_raw_m();
    float ss_amp_raw = current_strike_slip_peak_disturbance_raw_m();
    float fault_depth_half_width = fmaxf(uplift_half_across, fmaxf(subsidence_offset + subsidence_half_across, ss_half_across));

    int search_r = (int)ceilf(subsidence_offset + edge_offset + ss_half_along + ss_half_across + subsidence_half_across + edge_sub_half_across + 3.0f);
    int min_x = center_x - search_r;
    int max_x = center_x + search_r;
    int min_y = center_y - search_r;
    int max_y = center_y + search_r;

    for (int y = min_y; y <= max_y; ++y) {
        for (int x = min_x; x <= max_x; ++x) {
            float h = 0.0f;
            float footprint_w = 0.0f;

            float ux = (float)x - (float)center_x;
            float uy = (float)y - (float)center_y;
            float u_along = ux * strike_x + uy * strike_y;
            float u_across = ux * normal_x + uy * normal_y;
            float ss_along = ux * ss_strike_x + uy * ss_strike_y;
            float ss_across = ux * ss_normal_x + uy * ss_normal_y;

            float local_depth_km = fault_plane_cell_depth_km(center_x, center_y, x, y,
                                                             normal_x, normal_y,
                                                             fault_depth_half_width);
            float reverse_peak = reverse_peak_raw * thrust_tsunami_depth_factor_at_km(local_depth_km);
            float ss_amp = ss_amp_raw * strike_slip_tsunami_depth_factor_at_km(local_depth_km);

            if (reverse_mix > 0.0f) {
                /* Reverse/thrust uplift lobe. */
                float ur = sqrtf((u_along / uplift_half_along) * (u_along / uplift_half_along) +
                                 (u_across / uplift_half_across) * (u_across / uplift_half_across));
                float uw = raised_cosine_weight(ur);
                footprint_w = fmaxf(footprint_w, uw);
                h += reverse_peak * uw;

                /* Dominant down-dip/footwall reverse-fault depression. */
                float dxs = (float)x - sub_center_x;
                float dys = (float)y - sub_center_y;
                float s_along = dxs * strike_x + dys * strike_y;
                float s_across = dxs * normal_x + dys * normal_y;
                float sr = sqrtf((s_along / subsidence_half_along) * (s_along / subsidence_half_along) +
                                 (s_across / subsidence_half_across) * (s_across / subsidence_half_across));
                float sw = raised_cosine_weight(sr);
                footprint_w = fmaxf(footprint_w, sw);
                h -= reverse_peak * g_subsidence_ratio * sw;

                /* Weaker opposite-side compensation settlement. */
                float dxw = (float)x - weak_sub_center_x;
                float dyw = (float)y - weak_sub_center_y;
                float w_along = dxw * strike_x + dyw * strike_y;
                float w_across = dxw * normal_x + dyw * normal_y;
                float wr = sqrtf((w_along / (subsidence_half_along * 0.95f)) * (w_along / (subsidence_half_along * 0.95f)) +
                                 (w_across / (subsidence_half_across * 0.82f)) * (w_across / (subsidence_half_across * 0.82f)));
                float ww = raised_cosine_weight(wr);
                footprint_w = fmaxf(footprint_w, ww);
                h -= reverse_peak * g_subsidence_ratio * 0.28f * ww;

                /* Weak along-strike edge settlement near finite-fault tips. */
                float de1x = (float)x - edge1_center_x;
                float de1y = (float)y - edge1_center_y;
                float e1_along = de1x * strike_x + de1y * strike_y;
                float e1_across = de1x * normal_x + de1y * normal_y;
                float e1r = sqrtf((e1_along / edge_sub_half_along) * (e1_along / edge_sub_half_along) +
                                  (e1_across / edge_sub_half_across) * (e1_across / edge_sub_half_across));
                float e1w = raised_cosine_weight(e1r);

                float de2x = (float)x - edge2_center_x;
                float de2y = (float)y - edge2_center_y;
                float e2_along = de2x * strike_x + de2y * strike_y;
                float e2_across = de2x * normal_x + de2y * normal_y;
                float e2r = sqrtf((e2_along / edge_sub_half_along) * (e2_along / edge_sub_half_along) +
                                  (e2_across / edge_sub_half_across) * (e2_across / edge_sub_half_across));
                float e2w = raised_cosine_weight(e2r);

                float edge_w = fmaxf(e1w, e2w);
                footprint_w = fmaxf(footprint_w, edge_w);
                h -= reverse_peak * g_subsidence_ratio * 0.16f * edge_w;
            }

            if (strike_mix > 0.0f) {
                /*
                   Strike-slip uses its own 90-degree-corrected fault basis.

                   Instead of a simple two-lobe stamp, use a double-couple-like
                   cross pattern: adjacent quadrants alternate compression and
                   extension around the fault trace and its auxiliary plane.
                   This is not a full CMT/Okada solver, but it is closer to the
                   "cross" pattern implied by strike-slip focal mechanisms.
                */
                float ar = sqrtf((ss_along / ss_half_along) * (ss_along / ss_half_along) +
                                 (ss_across / ss_half_across) * (ss_across / ss_half_across));
                float aw = raised_cosine_weight(ar);

                float a = clampf_gui(ss_along / ss_half_along, -1.0f, 1.0f);
                float c = clampf_gui(ss_across / ss_half_across, -1.0f, 1.0f);

                float quadrant_sign = (a * c >= 0.0f) ? 1.0f : -1.0f;
                quadrant_sign *= (float)g_strike_slip_sense;

                /* Zero on the nodal axes, strongest inside the four quadrants. */
                float cross_strength = sqrtf(clampf_gui(fabsf(a * c) * 4.0f, 0.0f, 1.0f));

                /*
                   Keep a smaller fault-tip/restraining-bend style term so the
                   pattern still has along-fault asymmetry at the rupture ends.
                */
                float end_sign = clampf_gui(a, -1.0f, 1.0f);
                float side_taper = 1.0f - clampf_gui(fabsf(c), 0.0f, 1.0f);
                float tip_term = end_sign * side_taper;

                float slope_push = strike_slip_slope_push_factor(x, y, ss_strike_x, ss_strike_y, ss_normal_x, ss_normal_y);

                float ss_shape = 0.70f * quadrant_sign * cross_strength + 0.30f * (float)g_strike_slip_sense * tip_term;
                footprint_w = fmaxf(footprint_w, aw * fmaxf(cross_strength, side_taper));
                h += ss_amp * slope_push * ss_shape * aw;
            }

            if (fabsf(h) >= 0.0005f || ((reverse_peak <= 0.0001f && ss_amp <= 0.0001f) && footprint_w > 0.001f)) {
                /*
                   If depth makes tsunami height exactly zero, still register the
                   source footprint with h=0 so M0/Mw can use the drawn source area.
                */
                affected += set_source_cell_height(hwnd, x, y, h, delay, 1);
            }
        }
    }
    return affected;
}

static int paint_source_disc(HWND hwnd, int center_x, int center_y) {
    if (g_source_profile_mode) {
        return paint_source_reverse_fault(hwnd, center_x, center_y);
    }
    return paint_source_flat_disc(hwnd, center_x, center_y);
}


static float stamp_length_key_step_km(void) {
    if (GetKeyState(VK_CONTROL) & 0x8000) return 1.0f;
    if (GetKeyState(VK_SHIFT) & 0x8000) return 20.0f;
    return 5.0f;
}

static float normalized_stamp_length_km(float length_km) {
    if (!isfinite(length_km)) length_km = 40.0f;
    return clampf_gui(length_km, 5.0f, 600.0f);
}

static int normalized_stamp_delay_s(int delay_s) {
    if (delay_s < 0) delay_s = 0;
    if (delay_s > 7 * 24 * 3600) delay_s = 7 * 24 * 3600;
    return delay_s;
}

static int stamp_delay_key_step_s(void) {
    int step = g_dt_s;
    if (step <= 0) step = DEFAULT_DT_SECONDS;
    if (GetKeyState(VK_SHIFT) & 0x8000) step = 300;
    if (GetKeyState(VK_CONTROL) & 0x8000) step = 1;
    return step;
}

static int stamp_effective_delay_s(const StampFaultGroup *fg) {
    int delay = fg ? fg->delay_s : g_new_source_delay_s;
    delay = normalized_stamp_delay_s(delay);
    if (g_time_s >= delay) delay = g_time_s + 1;
    return delay;
}

static StampFaultGroup *stamp_current_group(void) {
    if (g_current_stamp_fault < 0 || g_current_stamp_fault >= g_stamp_fault_count) return NULL;
    return &g_stamp_faults[g_current_stamp_fault];
}

static StampFaultGroup *stamp_create_group_from_current_settings(void) {
    if (g_stamp_fault_count >= MAX_STAMP_FAULT_GROUPS) {
        MessageBeep(MB_ICONERROR);
        set_status("Stamp fault mode: maximum fault group count reached (%d).", MAX_STAMP_FAULT_GROUPS);
        return NULL;
    }
    int i = g_stamp_fault_count++;
    StampFaultGroup *g = &g_stamp_faults[i];
    memset(g, 0, sizeof(*g));
    g->id = i + 1;
    g->completed = 0;
    g->has_stamp = 0;
    g->last_x = -1;
    g->last_y = -1;
    g->origin_x = -1;
    g->origin_y = -1;
    g->subsidence_side = (g_subsidence_side < 0) ? -1 : 1;
    g->strike_deg = normalize_deg_gui(g_fault_strike_deg);
    g->dip_deg = source_stamp_dip_now_deg();
    g->top_depth_km = clampf_gui(g_source_depth_km, 0.0f, 700.0f);
    g->width_km = current_fault_width_km();
    g->length_km = normalized_stamp_length_km(g_fault_stamp_length_km);
    g->delay_s = normalized_stamp_delay_s(g_new_source_delay_s);
    g->stamp_count = 0;
    g_current_stamp_fault = i;
    return g;
}

static StampFaultGroup *stamp_ensure_open_group(void) {
    StampFaultGroup *g = stamp_current_group();
    if (!g || g->completed) g = stamp_create_group_from_current_settings();
    return g;
}

static int stamp_regenerate_auto_subsidence(HWND hwnd, int reset_after, int show_status);

static void stamp_finish_current_fault(void) {
    StampFaultGroup *g = stamp_current_group();
    if (!g) {
        set_status("Stamp fault mode: no active fault. Next click starts fault #%d.", g_stamp_fault_count + 1);
        return;
    }
    g->completed = 1;
    g_current_stamp_fault = -1;
    {
        int auto_cells = stamp_regenerate_auto_subsidence(NULL, 1, 0);
        set_status("Stamp fault #%d completed: %d patch(es), delay %ds, strike %.0f deg, dip %.0f deg, depth %.1f km. Auto secondary regenerated: %d cell(s). Next click starts a new fault.",
                   g->id, g->stamp_count, g->delay_s, g->strike_deg, g->dip_deg, g->top_depth_km, auto_cells);
    }
}

static void stamp_cycle_fault_group(void) {
    if (g_stamp_fault_count <= 0) {
        set_status("Stamp fault mode: no fault groups yet. Click to start fault #1.");
        return;
    }
    if (g_current_stamp_fault < 0) g_current_stamp_fault = 0;
    else g_current_stamp_fault = (g_current_stamp_fault + 1) % g_stamp_fault_count;
    StampFaultGroup *g = &g_stamp_faults[g_current_stamp_fault];
    set_status("Stamp fault #%d selected for review: %d patch(es), %s, delay %ds, strike %.0f. Completed groups are not extended; N/Enter then click starts a new fault.",
               g->id, g->stamp_count, g->completed ? "completed" : "open", g->delay_s, g->strike_deg);
}

static void stamp_runtime_sources_max_fault_id(void) {
    int max_id = 0;
    for (size_t i = 0; i < g_sources.count; ++i) {
        if (g_sources.items[i].fault_id > max_id) max_id = g_sources.items[i].fault_id;
    }
    if (max_id <= 0) return;
    if (max_id >= MAX_STAMP_FAULT_GROUPS) max_id = MAX_STAMP_FAULT_GROUPS - 1;
    if (g_stamp_fault_count < max_id) g_stamp_fault_count = max_id;
    for (int id = 1; id <= max_id; ++id) {
        StampFaultGroup *g = &g_stamp_faults[id - 1];
        memset(g, 0, sizeof(*g));
        g->id = id;
        g->completed = 1;
        g->last_x = -1;
        g->last_y = -1;
        g->origin_x = -1;
        g->origin_y = -1;
        g->subsidence_side = (g_subsidence_side < 0) ? -1 : 1;
        g->strike_deg = g_fault_strike_deg;
        g->dip_deg = g_fault_dip_deg;
        g->top_depth_km = g_source_depth_km;
        g->width_km = current_fault_width_km();
        g->length_km = normalized_stamp_length_km(g_fault_stamp_length_km);
        g->delay_s = -1;
        for (size_t i = 0; i < g_sources.count; ++i) {
            const SourceSpec *src = &g_sources.items[i];
            if (src->fault_id != id) continue;
            if (g->delay_s < 0 || src->delay_s < g->delay_s) g->delay_s = normalized_stamp_delay_s(src->delay_s);
            g->stamp_count++;
            g->has_stamp = 1;
            if (g->origin_x < 0 || g->origin_y < 0) {
                g->origin_x = src->grid_x;
                g->origin_y = src->grid_y;
            }
            g->last_x = src->grid_x;
            g->last_y = src->grid_y;
            if (src->stamp_strike_deg > 0.0f || src->stamp_depth_km > 0.0f || src->stamp_width_km > 0.0f) {
                g->strike_deg = normalize_deg_gui(src->stamp_strike_deg);
                if (src->stamp_dip_deg >= 5.0f && src->stamp_dip_deg <= 60.0f) g->dip_deg = src->stamp_dip_deg;
                if (src->stamp_depth_km >= 0.0f && src->stamp_depth_km <= 700.0f) g->top_depth_km = src->stamp_depth_km;
                if (src->stamp_width_km > 0.0f) g->width_km = normalized_fault_width_km(src->stamp_width_km);
                if (src->stamp_length_km > 0.0f) g->length_km = normalized_stamp_length_km(src->stamp_length_km);
            }
        }
        if (g->delay_s < 0) g->delay_s = normalized_stamp_delay_s(g_new_source_delay_s);
    }
}

typedef struct StampSavedSettings {
    float strike_deg;
    float dip_deg;
    float depth_km;
    float width_km;
    float length_km;
    int delay_s;
    int subsidence_side;
} StampSavedSettings;

static void stamp_apply_group_settings(const StampFaultGroup *g, StampSavedSettings *saved) {
    if (!g) return;
    if (saved) {
        saved->strike_deg = g_fault_strike_deg;
        saved->dip_deg = g_fault_dip_deg;
        saved->depth_km = g_source_depth_km;
        saved->width_km = g_fault_width_km;
        saved->length_km = g_fault_stamp_length_km;
        saved->delay_s = g_new_source_delay_s;
        saved->subsidence_side = g_subsidence_side;
    }
    g_fault_strike_deg = normalize_deg_gui(g->strike_deg);
    g_subsidence_side = (g->subsidence_side < 0) ? -1 : 1;
    g_fault_dip_deg = clampf_gui(g->dip_deg, 5.0f, 60.0f);
    g_source_depth_km = clampf_gui(g->top_depth_km, 0.0f, 700.0f);
    g_fault_width_km = normalized_fault_width_km(g->width_km);
    g_fault_stamp_length_km = normalized_stamp_length_km(g->length_km);
    g_new_source_delay_s = normalized_stamp_delay_s(g->delay_s);
}

static void stamp_restore_settings(const StampSavedSettings *saved) {
    if (!saved) return;
    g_fault_strike_deg = saved->strike_deg;
    g_fault_dip_deg = saved->dip_deg;
    g_source_depth_km = saved->depth_km;
    g_fault_width_km = saved->width_km;
    g_fault_stamp_length_km = saved->length_km;
    g_new_source_delay_s = saved->delay_s;
    g_subsidence_side = saved->subsidence_side;
}

static float stamp_patch_top_depth_km_for_center(const StampFaultGroup *fg, int center_x, int center_y) {
    if (!fg) return clampf_gui(g_source_depth_km, 0.0f, 700.0f);
    int ox = (fg->origin_x >= 0) ? fg->origin_x : center_x;
    int oy = (fg->origin_y >= 0) ? fg->origin_y : center_y;

    float sx, sy, nx, ny;
    reverse_fault_basis_for(fg->strike_deg, fg->subsidence_side, &sx, &sy, &nx, &ny);
    (void)sx;
    (void)sy;

    /* v45: keep all stamps on the first stamp's fault plane.
       Offset along strike does not change depth; offset along the down-dip normal
       changes the patch's shallow/top-edge depth by horizontal_offset * tan(dip). */
    float down_dip_cells = ((float)center_x - (float)ox) * nx +
                           ((float)center_y - (float)oy) * ny;
    float km_per_cell = local_ground_km_per_cell_mercator((float)((center_y + oy) * 0.5f));
    if (km_per_cell < 0.01f) km_per_cell = 0.01f;
    float dip_rad = clampf_gui(fg->dip_deg, 5.0f, 60.0f) * TSUNAMI_PI / 180.0f;
    float c = cosf(dip_rad);
    if (c < 0.25f) c = 0.25f;
    float extra_km = down_dip_cells * km_per_cell * (sinf(dip_rad) / c);
    return clampf_gui(fg->top_depth_km + extra_km, 0.0f, 700.0f);
}

static int paint_fault_stamp_single(HWND hwnd, StampFaultGroup *fg, int center_x, int center_y) {
    if (!fg) return 0;
    int affected = 0;
    int delay = stamp_effective_delay_s(fg);
    float patch_top_depth_km = stamp_patch_top_depth_km_for_center(fg, center_x, center_y);
    float saved_source_depth_km = g_source_depth_km;
    g_source_depth_km = patch_top_depth_km;
    float km_per_cell = local_ground_km_per_cell_mercator((float)center_y);
    if (km_per_cell < 0.01f) km_per_cell = 0.01f;

    float strike_x, strike_y, normal_x, normal_y;
    reverse_fault_basis(&strike_x, &strike_y, &normal_x, &normal_y);

    float dip = clampf_gui(fg->dip_deg, 5.0f, 60.0f);
    float projected_width_km = fg->width_km * fault_dip_cos_deg(dip);
    float half_along = fmaxf(1.5f, 0.5f * fg->length_km / km_per_cell);
    float half_across = fmaxf(1.0f, 0.5f * projected_width_km / km_per_cell);
    int search_r = (int)ceilf(sqrtf(half_along * half_along + half_across * half_across) + 2.0f);

    float reverse_peak_raw = current_reverse_peak_uplift_raw_m();
    float ss_amp_raw = current_strike_slip_peak_disturbance_raw_m();

    g_active_stamp_fault_id = fg->id;
    g_active_stamp_patch_id = g_next_stamp_patch_id++;

    for (int y = center_y - search_r; y <= center_y + search_r; ++y) {
        for (int x = center_x - search_r; x <= center_x + search_r; ++x) {
            if (x < 0 || y < 0 || x >= (int)g_grid.width || y >= (int)g_grid.height) continue;
            float ux = (float)x - (float)center_x;
            float uy = (float)y - (float)center_y;
            float along = ux * strike_x + uy * strike_y;
            float across = ux * normal_x + uy * normal_y;
            float aa = fabsf(along) / half_along;
            float ac = fabsf(across) / half_across;
            if (aa > 1.0f || ac > 1.0f) continue;

            float wa = raised_cosine_weight(aa);
            float wc = raised_cosine_weight(ac);
            float w = wa * wc;
            if (w < 0.012f) continue;

            float local_depth_km = fault_plane_cell_depth_km(center_x, center_y, x, y,
                                                             normal_x, normal_y, half_across);
            float peak = reverse_peak_raw * thrust_tsunami_depth_factor_at_km(local_depth_km) +
                         ss_amp_raw * strike_slip_tsunami_depth_factor_at_km(local_depth_km);
            float h = peak * w;
            if (fabsf(h) < 0.0005f) h = 0.0f;
            affected += set_source_cell_height(hwnd, x, y, h, delay, 1);
        }
    }

    g_active_stamp_fault_id = -1;
    g_active_stamp_patch_id = 0;
    g_source_depth_km = saved_source_depth_km;
    fg->stamp_count++;
    return affected;
}

typedef struct StampPatchCenterAccum {
    int patch_id;
    double sum_x;
    double sum_y;
    int count;
} StampPatchCenterAccum;

static int stamp_find_nearest_existing_patch_center(const StampFaultGroup *fg, int gx, int gy,
                                                    int *out_x, int *out_y, int *out_patch_id) {
    if (!fg || !fg->has_stamp) return 0;

    StampPatchCenterAccum *acc = NULL;
    size_t count = 0;
    size_t capacity = 0;

    for (size_t i = 0; i < g_sources.count; ++i) {
        const SourceSpec *src = &g_sources.items[i];
        if (src->fault_id != fg->id || src->fault_patch_id <= 0) continue;

        size_t j;
        for (j = 0; j < count; ++j) {
            if (acc[j].patch_id == src->fault_patch_id) break;
        }
        if (j == count) {
            if (count == capacity) {
                size_t new_capacity = capacity ? capacity * 2u : 32u;
                StampPatchCenterAccum *new_acc = (StampPatchCenterAccum *)realloc(acc, new_capacity * sizeof(StampPatchCenterAccum));
                if (!new_acc) {
                    free(acc);
                    if (fg->last_x >= 0 && fg->last_y >= 0) {
                        if (out_x) *out_x = fg->last_x;
                        if (out_y) *out_y = fg->last_y;
                        if (out_patch_id) *out_patch_id = 0;
                        return 1;
                    }
                    return 0;
                }
                acc = new_acc;
                capacity = new_capacity;
            }
            acc[j].patch_id = src->fault_patch_id;
            acc[j].sum_x = 0.0;
            acc[j].sum_y = 0.0;
            acc[j].count = 0;
            count++;
        }
        acc[j].sum_x += (double)src->grid_x;
        acc[j].sum_y += (double)src->grid_y;
        acc[j].count++;
    }

    if (count == 0) {
        free(acc);
        if (fg->last_x >= 0 && fg->last_y >= 0) {
            if (out_x) *out_x = fg->last_x;
            if (out_y) *out_y = fg->last_y;
            if (out_patch_id) *out_patch_id = 0;
            return 1;
        }
        return 0;
    }

    int best_x = fg->last_x;
    int best_y = fg->last_y;
    int best_patch_id = 0;
    double best_d2 = 1.0e300;
    for (size_t j = 0; j < count; ++j) {
        if (acc[j].count <= 0) continue;
        double cx = acc[j].sum_x / (double)acc[j].count;
        double cy = acc[j].sum_y / (double)acc[j].count;
        double dx = cx - (double)gx;
        double dy = cy - (double)gy;
        double d2 = dx * dx + dy * dy;
        if (d2 < best_d2) {
            best_d2 = d2;
            best_x = (int)lround(cx);
            best_y = (int)lround(cy);
            best_patch_id = acc[j].patch_id;
        }
    }
    free(acc);

    if (best_patch_id <= 0 && fg->last_x < 0) return 0;
    if (out_x) *out_x = best_x;
    if (out_y) *out_y = best_y;
    if (out_patch_id) *out_patch_id = best_patch_id;
    return 1;
}

static int stamp_add_fault_patch(HWND hwnd, int gx, int gy) {
    source_undo_push("stamp fault patch");
    StampFaultGroup *fg = stamp_ensure_open_group();
    if (!fg) return 0;
    if (!fg->has_stamp || fg->origin_x < 0 || fg->origin_y < 0) {
        fg->origin_x = gx;
        fg->origin_y = gy;
        fg->top_depth_km = clampf_gui(fg->top_depth_km, 0.0f, 700.0f);
    }

    StampSavedSettings saved;
    stamp_apply_group_settings(fg, &saved);

    int affected = 0;
    int bridge_count = 0;
    int anchor_x = -1;
    int anchor_y = -1;
    int anchor_patch_id = 0;
    int have_anchor = stamp_find_nearest_existing_patch_center(fg, gx, gy, &anchor_x, &anchor_y, &anchor_patch_id);
    if (have_anchor) {
        float dx = (float)(gx - anchor_x);
        float dy = (float)(gy - anchor_y);
        float dist = sqrtf(dx * dx + dy * dy);
        float km_per_cell = local_ground_km_per_cell_mercator((float)((gy + anchor_y) * 0.5f));
        if (km_per_cell < 0.01f) km_per_cell = 0.01f;
        float spacing = fmaxf(2.0f, (fg->length_km / km_per_cell) * 0.72f);
        bridge_count = (int)floorf(dist / spacing);
        if (bridge_count > 256) bridge_count = 256;
        for (int k = 1; k <= bridge_count; ++k) {
            float t = (float)k / (float)(bridge_count + 1);
            int bx = (int)lroundf((float)anchor_x + dx * t);
            int by = (int)lroundf((float)anchor_y + dy * t);
            affected += paint_fault_stamp_single(hwnd, fg, bx, by);
        }
    }

    affected += paint_fault_stamp_single(hwnd, fg, gx, gy);
    fg->last_x = gx;
    fg->last_y = gy;
    fg->has_stamp = 1;
    stamp_restore_settings(&saved);
    sanitize_all_source_node_slips();

    float target_patch_depth_km = stamp_patch_top_depth_km_for_center(fg, gx, gy);
    if (have_anchor) {
        set_status("Stamp fault #%d: added patch at x=%d y=%d, connected to nearest patch #%d at x=%d y=%d, bridge=%d, affected=%d, delay %ds, strike %.0f, dip %.0f, patch depth %.1f km (origin %.1f), length %.1f km, width %.1f km, Mw=%.2f.",
                   fg->id, gx, gy, anchor_patch_id, anchor_x, anchor_y, bridge_count, affected, fg->delay_s,
                   fg->strike_deg, fg->dip_deg, target_patch_depth_km, fg->top_depth_km, fg->length_km, fg->width_km, current_moment_magnitude());
    } else {
        set_status("Stamp fault #%d: added first patch at x=%d y=%d, affected=%d, delay %ds, strike %.0f, dip %.0f, origin depth %.1f km, length %.1f km, width %.1f km, Mw=%.2f.",
                   fg->id, gx, gy, affected, fg->delay_s, fg->strike_deg, fg->dip_deg,
                   fg->top_depth_km, fg->length_km, fg->width_km, current_moment_magnitude());
    }
    return affected;
}


static StampFaultGroup *stamp_group_by_id(int fault_id) {
    if (fault_id <= 0) return NULL;
    for (int i = 0; i < g_stamp_fault_count; ++i) {
        if (g_stamp_faults[i].id == fault_id) return &g_stamp_faults[i];
    }
    return NULL;
}

static int stamp_source_is_auto_subsidence(const SourceSpec *src) {
    if (!src) return 0;
    /* v48: patch_id < 0 means generated secondary deformation.  It can be
       negative subsidence or a weak positive uplift shoulder, so remove it
       regardless of sign before regenerating. */
    return (src->fault_id > 0 && src->fault_patch_id < 0);
}

static int stamp_source_is_direct_rupture_stamp(const SourceSpec *src) {
    if (!src) return 0;
    if (src->fault_id <= 0 || src->fault_patch_id <= 0) return 0;
    return source_node_counts_for_moment_area(src);
}

static int stamp_remove_auto_subsidence_sources(void) {
    int removed = 0;
    for (size_t i = 0; i < g_sources.count; ) {
        if (stamp_source_is_auto_subsidence(&g_sources.items[i])) {
            if (i + 1u < g_sources.count) {
                memmove(&g_sources.items[i], &g_sources.items[i + 1u],
                        (g_sources.count - i - 1u) * sizeof(SourceSpec));
            }
            g_sources.count--;
            removed++;
            continue;
        }
        i++;
    }
    return removed;
}

typedef struct StampFaultEnvelope {
    int valid;
    int fault_id;
    int source_cells;
    int delay_s;
    int subsidence_side;
    float strike_deg;
    float dip_deg;
    float width_km;
    float length_km;
    float top_depth_km;
    float sx, sy, nx, ny;
    float min_s, max_s;
    float min_n, max_n;
    float max_amp_m;
    double sum_depth;
    double sum_x;
    double sum_y;
} StampFaultEnvelope;

static int stamp_fault_env_index_by_id(StampFaultEnvelope *envs, int count, int fault_id) {
    for (int i = 0; i < count; ++i) {
        if (envs[i].fault_id == fault_id) return i;
    }
    return -1;
}

static void stamp_fault_env_init_from_source(StampFaultEnvelope *env, const SourceSpec *src) {
    if (!env || !src) return;
    memset(env, 0, sizeof(*env));
    env->valid = 1;
    env->fault_id = src->fault_id;
    env->delay_s = normalized_stamp_delay_s(src->delay_s);
    env->subsidence_side = (g_subsidence_side < 0) ? -1 : 1;
    StampFaultGroup *fg = stamp_group_by_id(src->fault_id);
    if (fg) {
        env->subsidence_side = (fg->subsidence_side < 0) ? -1 : 1;
        env->strike_deg = normalize_deg_gui(fg->strike_deg);
        env->dip_deg = clampf_gui(fg->dip_deg, 5.0f, 60.0f);
        env->width_km = normalized_fault_width_km(fg->width_km);
        env->length_km = normalized_stamp_length_km(fg->length_km);
        env->top_depth_km = clampf_gui(fg->top_depth_km, 0.0f, 700.0f);
    } else {
        env->strike_deg = normalize_deg_gui(src->stamp_strike_deg);
        env->dip_deg = source_node_stamp_dip_deg(src);
        env->width_km = (src->stamp_width_km > 0.0f) ? normalized_fault_width_km(src->stamp_width_km) : current_fault_width_km();
        env->length_km = (src->stamp_length_km > 0.0f) ? normalized_stamp_length_km(src->stamp_length_km) : g_fault_stamp_length_km;
        env->top_depth_km = clampf_gui(src->stamp_depth_km, 0.0f, 700.0f);
    }
    reverse_fault_basis_for(env->strike_deg, env->subsidence_side, &env->sx, &env->sy, &env->nx, &env->ny);
    env->min_s = env->min_n =  1.0e30f;
    env->max_s = env->max_n = -1.0e30f;
    env->max_amp_m = 0.0f;
}

static void stamp_fault_env_accumulate_source(StampFaultEnvelope *env, const SourceSpec *src) {
    if (!env || !src) return;
    float s = (float)src->grid_x * env->sx + (float)src->grid_y * env->sy;
    float n = (float)src->grid_x * env->nx + (float)src->grid_y * env->ny;
    if (s < env->min_s) env->min_s = s;
    if (s > env->max_s) env->max_s = s;
    if (n < env->min_n) env->min_n = n;
    if (n > env->max_n) env->max_n = n;
    float amp = fabsf(src->height_m);
    if (amp < 0.0005f && src->slip_m > SOURCE_SLIP_HEIGHT_EPS_M) {
        float dip_rad = env->dip_deg * TSUNAMI_PI / 180.0f;
        float reverse_mix = clampf_gui(1.0f - g_strike_slip_mix, 0.0f, 1.0f);
        amp = src->slip_m * sinf(dip_rad) * reverse_mix *
              thrust_tsunami_depth_factor_at_km(clampf_gui(src->stamp_depth_km, 0.0f, 700.0f));
    }
    if (amp > env->max_amp_m) env->max_amp_m = amp;
    if (src->delay_s < env->delay_s) env->delay_s = normalized_stamp_delay_s(src->delay_s);
    env->sum_depth += (double)clampf_gui(src->stamp_depth_km, 0.0f, 700.0f);
    env->sum_x += (double)src->grid_x;
    env->sum_y += (double)src->grid_y;
    env->source_cells++;
}

static void stamp_mark_continuous_fault_support(uint8_t *occupied, const StampFaultEnvelope *env) {
    if (!occupied || !env || !env->valid || env->source_cells <= 0) return;
    float center_s = 0.5f * (env->min_s + env->max_s);
    float center_n = 0.5f * (env->min_n + env->max_n);
    float half_s = fmaxf(1.5f, 0.5f * (env->max_s - env->min_s));
    float half_n = fmaxf(1.5f, 0.5f * (env->max_n - env->min_n));
    /* Expand and round the interpolation support.  This treats several stamp
       patches as one continuous finite-fault ribbon but avoids the v48
       rectangular corner notches that made the generated field look blocky. */
    half_s += 2.5f;
    half_n += 2.2f;

    float cx = center_s * env->sx + center_n * env->nx;
    float cy = center_s * env->sy + center_n * env->ny;
    int search_r = (int)ceilf(sqrtf(half_s * half_s + half_n * half_n) + 3.0f);
    for (int y = (int)floorf(cy - search_r); y <= (int)ceilf(cy + search_r); ++y) {
        if (y < 0 || y >= (int)g_grid.height) continue;
        for (int x = (int)floorf(cx - search_r); x <= (int)ceilf(cx + search_r); ++x) {
            if (x < 0 || x >= (int)g_grid.width) continue;
            float s = (float)x * env->sx + (float)y * env->sy;
            float n = (float)x * env->nx + (float)y * env->ny;
            float as = fabsf(s - center_s) / half_s;
            float an = fabsf(n - center_n) / half_n;
            if (rounded_superellipse_weight(as, an, 3.2f) > 0.0f) {
                size_t idx = tsunami_index(&g_grid, (uint32_t)x, (uint32_t)y);
                occupied[idx] = 1;
            }
        }
    }
}

static void stamp_secondary_try_set(float *sec_h,
                                    int32_t *sec_delay,
                                    int32_t *sec_fault_id,
                                    int32_t *sec_patch_id,
                                    float *sec_strike,
                                    float *sec_dip,
                                    float *sec_depth,
                                    float *sec_width,
                                    float *sec_length,
                                    uint8_t *occupied,
                                    const StampFaultEnvelope *env,
                                    int x, int y, float candidate) {
    if (!sec_h || !env) return;
    if (x < 0 || y < 0 || x >= (int)g_grid.width || y >= (int)g_grid.height) return;
    size_t idx = tsunami_index(&g_grid, (uint32_t)x, (uint32_t)y);
    if (occupied && occupied[idx]) return;
    if (g_grid.solid[idx]) return;
    if (fabsf(candidate) < 0.0005f) return;
    /* Use the strongest signed secondary deformation if multiple lobe pieces or
       neighbouring faults overlap.  This avoids runaway stacking while still
       allowing a weak positive shoulder to replace a weaker negative one. */
    if (fabsf(candidate) <= fabsf(sec_h[idx])) return;
    sec_h[idx] = candidate;
    sec_delay[idx] = normalized_stamp_delay_s(env->delay_s);
    sec_fault_id[idx] = env->fault_id;
    sec_patch_id[idx] = -999; /* generated secondary deformation, not a real patch */
    sec_strike[idx] = normalize_deg_gui(env->strike_deg);
    sec_dip[idx] = clampf_gui(env->dip_deg, 5.0f, 60.0f);
    sec_depth[idx] = (env->source_cells > 0) ? (float)(env->sum_depth / (double)env->source_cells) : env->top_depth_km;
    sec_width[idx] = env->width_km;
    sec_length[idx] = env->length_km;
}

static void stamp_generate_secondary_for_envelope(float *sec_h,
                                                  int32_t *sec_delay,
                                                  int32_t *sec_fault_id,
                                                  int32_t *sec_patch_id,
                                                  float *sec_strike,
                                                  float *sec_dip,
                                                  float *sec_depth,
                                                  float *sec_width,
                                                  float *sec_length,
                                                  uint8_t *occupied,
                                                  const StampFaultEnvelope *env) {
    if (!env || !env->valid || env->source_cells <= 0) return;
    if (env->max_amp_m < 0.0005f) return;

    float center_s = 0.5f * (env->min_s + env->max_s);
    float center_n = 0.5f * (env->min_n + env->max_n);
    float half_s = fmaxf(2.0f, 0.5f * (env->max_s - env->min_s) + 1.5f);
    float half_n = fmaxf(1.5f, 0.5f * (env->max_n - env->min_n) + 1.2f);

    float cx = center_s * env->sx + center_n * env->nx;
    float cy = center_s * env->sy + center_n * env->ny;
    float km_per_cell = local_ground_km_per_cell_mercator(cy);
    if (km_per_cell < 0.01f) km_per_cell = 0.01f;

    float projected_width_km = normalized_fault_width_km(env->width_km) * fault_dip_cos_deg(env->dip_deg);
    float width_cells = fmaxf(2.0f, projected_width_km / km_per_cell);
    half_n = fmaxf(half_n, 0.5f * width_cells);
    half_s = fmaxf(half_s, 0.45f * normalized_stamp_length_km(env->length_km) / km_per_cell);

    float ratio = clampf_gui(g_subsidence_ratio, 0.0f, 1.0f);
    float base_amp = env->max_amp_m;

    /* Continuous-fault secondary pattern, intentionally weaker than the direct
       stamp uplift.  It is not a full Okada solver, but follows the qualitative
       elastic-dislocation pattern: a broad down-dip/footwall settlement lobe,
       settlement near finite-fault tips, and weak positive shoulders along the
       opposite long edge so the whole outside of the fault is not depressed.

       v49: make the secondary field wider and rounder.  v48 used half_n around
       0.55*projected_width, which was too narrow after the continuous ribbon
       conversion.  The values below deliberately spread the second-order field
       over roughly one projected fault width outside the ribbon. */
    float main_offset = half_n + fmaxf(2.2f, 0.78f * width_cells);
    float main_half_s = half_s * 1.18f;
    float main_half_n = fmaxf(2.8f, 1.08f * width_cells);

    float shoulder_offset = -(half_n + fmaxf(1.8f, 0.52f * width_cells));
    float shoulder_half_s = half_s * 1.08f;
    float shoulder_half_n = fmaxf(2.1f, 0.64f * width_cells);

    float tip_offset_s = half_s + fmaxf(2.0f, 0.55f * normalized_stamp_length_km(env->length_km) / km_per_cell);
    float tip_half_s = fmaxf(2.2f, 0.42f * normalized_stamp_length_km(env->length_km) / km_per_cell);
    float tip_half_n = fmaxf(3.0f, half_n + 0.95f * width_cells);

    int search_r = (int)ceilf(fmaxf(tip_offset_s + tip_half_s, main_offset + main_half_n) + half_s + half_n + 4.0f);
    for (int y = (int)floorf(cy - search_r); y <= (int)ceilf(cy + search_r); ++y) {
        if (y < 0 || y >= (int)g_grid.height) continue;
        for (int x = (int)floorf(cx - search_r); x <= (int)ceilf(cx + search_r); ++x) {
            if (x < 0 || x >= (int)g_grid.width) continue;
            float s = (float)x * env->sx + (float)y * env->sy;
            float n = (float)x * env->nx + (float)y * env->ny;
            float ds = s - center_s;
            float dn = n - center_n;

            float candidate = 0.0f;

            /* Main down-dip side subsidence. */
            {
                float as = fabsf(ds) / main_half_s;
                float an = fabsf(dn - main_offset) / main_half_n;
                float w = rounded_superellipse_weight(as, an, 2.35f);
                if (w > 0.0f) {
                    candidate += -base_amp * ratio * 0.68f * w;
                }
            }

            /* Finite-fault tip settlement at both along-strike ends. */
            for (int sign = -1; sign <= 1; sign += 2) {
                float as = fabsf(ds - (float)sign * tip_offset_s) / tip_half_s;
                float an = fabsf(dn) / tip_half_n;
                float w = rounded_superellipse_weight(as, an, 2.15f);
                if (w > 0.0f) {
                    candidate += -base_amp * ratio * 0.24f * w;
                }
            }

            /* Weak uplift shoulder on the opposite long edge.  This keeps the
               secondary deformation from being "subsidence in every direction"
               and better matches the side-lobes of finite rectangular reverse
               dislocations. */
            {
                float as = fabsf(ds) / shoulder_half_s;
                float an = fabsf(dn - shoulder_offset) / shoulder_half_n;
                float w = rounded_superellipse_weight(as, an, 2.25f);
                if (w > 0.0f) {
                    candidate += base_amp * ratio * 0.10f * w;
                }
            }

            /* Very small positive shoulders along the long strike-parallel
               flanks just outside the filled rupture ribbon. */
            for (int side = -1; side <= 1; side += 2) {
                float flank_center = (float)side * (half_n + fmaxf(1.4f, 0.26f * width_cells));
                float as = fabsf(ds) / (half_s * 1.02f);
                float an = fabsf(dn - flank_center) / fmaxf(1.6f, 0.34f * width_cells);
                float w = rounded_superellipse_weight(as, an, 2.4f);
                if (w > 0.0f) {
                    candidate += base_amp * ratio * 0.035f * w;
                }
            }

            stamp_secondary_try_set(sec_h, sec_delay, sec_fault_id, sec_patch_id,
                                    sec_strike, sec_dip, sec_depth, sec_width, sec_length,
                                    occupied, env, x, y, candidate);
        }
    }
}

static int stamp_regenerate_auto_subsidence(HWND hwnd, int reset_after, int show_status) {
    (void)hwnd;
    if (g_grid.width == 0 || g_grid.height == 0) return 0;

    int removed = stamp_remove_auto_subsidence_sources();
    if (g_stamp_fault_count <= 0) stamp_runtime_sources_max_fault_id();

    const size_t cell_count = (size_t)g_grid.width * (size_t)g_grid.height;
    float *sec_h = (float *)calloc(cell_count, sizeof(float));
    uint8_t *occupied = (uint8_t *)calloc(cell_count, 1);
    int32_t *sec_delay = (int32_t *)malloc(cell_count * sizeof(int32_t));
    int32_t *sec_fault_id = (int32_t *)calloc(cell_count, sizeof(int32_t));
    int32_t *sec_patch_id = (int32_t *)calloc(cell_count, sizeof(int32_t));
    float *sec_strike = (float *)calloc(cell_count, sizeof(float));
    float *sec_dip = (float *)calloc(cell_count, sizeof(float));
    float *sec_depth = (float *)calloc(cell_count, sizeof(float));
    float *sec_width = (float *)calloc(cell_count, sizeof(float));
    float *sec_length = (float *)calloc(cell_count, sizeof(float));

    if (!sec_h || !occupied || !sec_delay || !sec_fault_id || !sec_patch_id ||
        !sec_strike || !sec_dip || !sec_depth || !sec_width || !sec_length) {
        free(sec_h); free(occupied); free(sec_delay); free(sec_fault_id); free(sec_patch_id);
        free(sec_strike); free(sec_dip); free(sec_depth); free(sec_width); free(sec_length);
        set_status("Auto secondary deformation: out of memory; previous generated nodes removed=%d.", removed);
        if (reset_after) reset_state_preserve_sources();
        return 0;
    }
    for (size_t i = 0; i < cell_count; ++i) sec_delay[i] = INT_MAX;

    size_t source_count = g_sources.count;
    int direct_count = 0;
    int fault_env_count = 0;
    StampFaultEnvelope envs[MAX_STAMP_FAULT_GROUPS];
    memset(envs, 0, sizeof(envs));

    /* Occupy all existing source nodes first.  Generated secondaries must never
       overwrite manually stamped/painted source cells. */
    for (size_t i = 0; i < source_count; ++i) {
        const SourceSpec *src = &g_sources.items[i];
        if (src->grid_x < 0 || src->grid_y < 0 || src->grid_x >= (int)g_grid.width || src->grid_y >= (int)g_grid.height) continue;
        size_t idx = tsunami_index(&g_grid, (uint32_t)src->grid_x, (uint32_t)src->grid_y);
        occupied[idx] = 1;
    }

    /* Build one continuous envelope per fault_id from all direct rupture stamp
       cells.  This is the interpolation -> single-fault conversion step. */
    for (size_t i = 0; i < source_count; ++i) {
        const SourceSpec *src = &g_sources.items[i];
        if (!stamp_source_is_direct_rupture_stamp(src)) continue;
        if (src->grid_x < 0 || src->grid_y < 0 || src->grid_x >= (int)g_grid.width || src->grid_y >= (int)g_grid.height) continue;
        int j = stamp_fault_env_index_by_id(envs, fault_env_count, src->fault_id);
        if (j < 0) {
            if (fault_env_count >= MAX_STAMP_FAULT_GROUPS) continue;
            j = fault_env_count++;
            stamp_fault_env_init_from_source(&envs[j], src);
        }
        stamp_fault_env_accumulate_source(&envs[j], src);
        direct_count++;
    }

    for (int j = 0; j < fault_env_count; ++j) {
        if (!envs[j].valid || envs[j].source_cells <= 0) continue;
        stamp_mark_continuous_fault_support(occupied, &envs[j]);
    }

    for (int j = 0; j < fault_env_count; ++j) {
        if (!envs[j].valid || envs[j].source_cells <= 0) continue;
        stamp_generate_secondary_for_envelope(sec_h, sec_delay, sec_fault_id, sec_patch_id,
                                              sec_strike, sec_dip, sec_depth, sec_width, sec_length,
                                              occupied, &envs[j]);
    }

    int added = 0;
    int added_sub = 0;
    int added_uplift = 0;
    for (size_t idx = 0; idx < cell_count; ++idx) {
        if (fabsf(sec_h[idx]) < 0.0005f) continue;
        int x = (int)(idx % (size_t)g_grid.width);
        int y = (int)(idx / (size_t)g_grid.width);
        int delay = (sec_delay[idx] == INT_MAX) ? g_new_source_delay_s : (int)sec_delay[idx];
        source_list_append(&g_sources, x, y, sec_h[idx], delay);
        if (g_sources.count > 0) {
            SourceSpec *dst = &g_sources.items[g_sources.count - 1];
            dst->slip_m = 0.0f;
            dst->fault_id = sec_fault_id[idx];
            dst->fault_patch_id = sec_patch_id[idx];
            dst->stamp_strike_deg = sec_strike[idx];
            dst->stamp_dip_deg = sec_dip[idx];
            dst->stamp_depth_km = sec_depth[idx];
            dst->stamp_width_km = sec_width[idx];
            dst->stamp_length_km = sec_length[idx];
            dst->moment_area_factor = 0.0f;
        }
        if (sec_h[idx] < 0.0f) added_sub++; else added_uplift++;
        added++;
    }

    free(sec_h); free(occupied); free(sec_delay); free(sec_fault_id); free(sec_patch_id);
    free(sec_strike); free(sec_dip); free(sec_depth); free(sec_width); free(sec_length);

    sanitize_all_source_node_slips();
    if (reset_after) reset_state_preserve_sources();
    if (show_status) {
        set_status("Auto secondary deformation regenerated: faults=%d, direct cells=%d, removed old=%d, added=%d (%d subsidence, %d weak uplift). Filled fault ribbons protect gaps between stamps.",
                   fault_env_count, direct_count, removed, added, added_sub, added_uplift);
    }
    return added;
}

static void stamp_adjust_delay(int delta_s) {
    StampFaultGroup *g = stamp_current_group();
    if (g && !g->completed) {
        g->delay_s = normalized_stamp_delay_s(g->delay_s + delta_s);
        set_status("Stamp fault #%d delay = %d s. This applies to new patches in the open fault. Step %d s.",
                   g->id, g->delay_s, abs(delta_s));
    } else {
        g_new_source_delay_s = normalized_stamp_delay_s(g_new_source_delay_s + delta_s);
        set_status("Stamp mode: next new fault delay = %d s. Step %d s. Open faults freeze their own delay.",
                   g_new_source_delay_s, abs(delta_s));
    }
}


static float stamp_safe_aspect_ratio(float length_km, float width_km) {
    float w = normalized_fault_width_km(width_km);
    float l = normalized_stamp_length_km(length_km);
    if (w <= 0.001f || !isfinite(w)) return 2.0f;
    float ratio = l / w;
    if (!isfinite(ratio) || ratio < 0.05f) ratio = 2.0f;
    return clampf_gui(ratio, 0.05f, 50.0f);
}

static void stamp_adjust_width_keep_aspect(float delta_km) {
    StampFaultGroup *g = stamp_current_group();
    if (g && !g->completed) {
        float old_width = normalized_fault_width_km(g->width_km);
        float ratio = stamp_safe_aspect_ratio(g->length_km, old_width);
        float new_width = normalized_fault_width_km(old_width + delta_km);
        float new_length = normalized_stamp_length_km(new_width * ratio);
        g->width_km = new_width;
        g->length_km = new_length;
        /* Mirror the open group into the normal settings panel so the user sees
           the actual stamp rectangle that will be used for the next click. */
        set_fault_width_km_value(new_width);
        g_fault_stamp_length_km = new_length;
        set_status("Stamp fault #%d size = %.1f km width x %.1f km length. Aspect kept %.2f:1. Step %.1f km.",
                   g->id, g->width_km, g->length_km, ratio, fabsf(delta_km));
    } else {
        float old_width = current_fault_width_km();
        float ratio = stamp_safe_aspect_ratio(g_fault_stamp_length_km, old_width);
        float new_width = normalized_fault_width_km(old_width + delta_km);
        float new_length = normalized_stamp_length_km(new_width * ratio);
        set_fault_width_km_value(new_width);
        g_fault_stamp_length_km = new_length;
        set_status("Stamp mode: next new fault size = %.1f km width x %.1f km length. Aspect kept %.2f:1. Step %.1f km.",
                   current_fault_width_km(), g_fault_stamp_length_km, ratio, fabsf(delta_km));
    }
}

static int stamp_mode_handle_key(HWND hwnd, WPARAM key) {
    (void)hwnd;
    switch (key) {
    case VK_RETURN:
    case 'N':
        stamp_finish_current_fault();
        return 1;
    case VK_TAB:
        stamp_cycle_fault_group();
        return 1;
    case 'Z': {
        float step = strike_rotation_key_step_deg();
        g_fault_strike_deg = normalize_deg_gui(g_fault_strike_deg - step);
        set_status("Stamp fault mode: next new fault strike = %.0f deg. Existing/open fault groups keep their frozen strike.", g_fault_strike_deg);
        return 1;
    }
    case 'X': {
        float step = strike_rotation_key_step_deg();
        g_fault_strike_deg = normalize_deg_gui(g_fault_strike_deg + step);
        set_status("Stamp fault mode: next new fault strike = %.0f deg. Existing/open fault groups keep their frozen strike.", g_fault_strike_deg);
        return 1;
    }
    case VK_OEM_4:
        stamp_adjust_delay(-stamp_delay_key_step_s());
        return 1;
    case VK_OEM_6:
        stamp_adjust_delay(stamp_delay_key_step_s());
        return 1;
    case VK_OEM_COMMA: {
        float step = fault_width_key_step_km();
        stamp_adjust_width_keep_aspect(-step);
        return 1;
    }
    case VK_OEM_PERIOD: {
        float step = fault_width_key_step_km();
        stamp_adjust_width_keep_aspect(step);
        return 1;
    }
    case 'J': {
        float step = stamp_length_key_step_km();
        g_fault_stamp_length_km = normalized_stamp_length_km(g_fault_stamp_length_km - step);
        set_status("Stamp fault mode: next new fault patch length = %.1f km. Open fault groups keep their frozen length.", g_fault_stamp_length_km);
        return 1;
    }
    case 'K': {
        float step = stamp_length_key_step_km();
        g_fault_stamp_length_km = normalized_stamp_length_km(g_fault_stamp_length_km + step);
        set_status("Stamp fault mode: next new fault patch length = %.1f km. Open fault groups keep their frozen length.", g_fault_stamp_length_km);
        return 1;
    }
    case 'G':
        source_undo_push("regenerate stamp auto subsidence");
        stamp_regenerate_auto_subsidence(hwnd, 1, 1);
        return 1;
    }
    return 0;
}

static float smoothstep01_gui(float u) {
    u = clampf_gui(u, 0.0f, 1.0f);
    return u * u * (3.0f - 2.0f * u);
}

static float strike_slip_long_segment_blend_weight(void) {
    /*
       Blend by the actual strike-slip ratio.

       If the source is not pure reverse and not pure strike-slip, both
       components should still exist.  So use the strike-slip fraction itself
       as the blend weight:
         0.0 = pure reverse
         1.0 = pure strike-slip
       and everything in between mixes continuously by ratio.
    */
    return clampf_gui(g_strike_slip_mix, 0.0f, 1.0f);
}

static int repaint_strike_slip_long_segment(HWND hwnd,
                                            int x0, int y0, int x1, int y1,
                                            float start_cursor_s,
                                            size_t source_start_index) {
    /*
       A long strike-slip fault should not look like many independent point
       double-couples.  Repaint mostly-strike-slip strokes as one finite fault
       object: stretched weaker middle, stronger terminal lobes.
    */
    float strike_mix = clampf_gui(g_strike_slip_mix, 0.0f, 1.0f);
    float ss_segment_blend = strike_slip_long_segment_blend_weight();
    if (ss_segment_blend <= 0.0001f) return 0;
    if (g_source_profile_mode == 0) return 0;
    if (x0 == x1 && y0 == y1) return 0;

    float dx = (float)(x1 - x0);
    float dy = (float)(y1 - y0);
    float len_cells = sqrtf(dx * dx + dy * dy);
    if (len_cells < 2.0f) return 0;

    float ux = dx / len_cells;
    float uy = dy / len_cells;
    float nx = -uy;
    float ny = ux;

    int r = reverse_profile_radius_cells_at_y(((float)y0 + (float)y1) * 0.5f);
    float width_scale = 1.0f;
    float half_across = (float)clampi_gui((int)lroundf((float)r * 0.90f * width_scale), 2, 95);
    float transition = fmaxf((float)r * 2.0f, 4.0f);
    float tip_len = fmaxf((float)r * 2.8f, 5.0f);
    float tip_width = fmaxf(half_across * 1.25f, 3.0f);

    float ss_amp_raw = current_strike_slip_peak_disturbance_raw_m();
    if (fabsf(ss_amp_raw) < 0.0005f && current_strike_slip_depth_factor() > 0.0001f) return 0;

    float rv = clampf_gui(g_rupture_velocity_km_s, 0.01f, 20.0f);
    float km_total = grid_segment_distance_km(x0, y0, x1, y1);

    int pad = (int)ceilf(tip_len + tip_width + 4.0f);
    int min_x = (x0 < x1 ? x0 : x1) - pad;
    int max_x = (x0 > x1 ? x0 : x1) + pad;
    int min_y = (y0 < y1 ? y0 : y1) - pad;
    int max_y = (y0 > y1 ? y0 : y1) + pad;

    int affected = 0;

    /*
       Only at nearly pure strike-slip do we remove point-stamp artifacts and
       repaint as a clean finite fault segment.  For any mixed mechanism,
       keep the existing reverse/point-stamp component and add a strike-slip
       finite-fault correction in proportion to the ratio.
    */
    if (ss_segment_blend >= 0.999f) {
        clear_source_items_from_index(source_start_index);
    }

    for (int y = min_y; y <= max_y; ++y) {
        for (int x = min_x; x <= max_x; ++x) {
            float px = (float)x - (float)x0;
            float py = (float)y - (float)y0;

            float along = px * ux + py * uy;
            float across = px * nx + py * ny;

            float ac = fabsf(across) / half_across;
            if (ac > 1.35f) continue;

            float side_weight = raised_cosine_weight(ac);
            if (side_weight <= 0.0f) continue;

            float t = along / len_cells;
            float middle = smoothstep01_gui(along / transition) *
                           smoothstep01_gui((len_cells - along) / transition);
            if (along < 0.0f || along > len_cells) middle = 0.0f;

            float left_tip = raised_cosine_weight(sqrtf((along / tip_len) * (along / tip_len) +
                                                        (across / tip_width) * (across / tip_width)));
            float right_d = along - len_cells;
            float right_tip = raised_cosine_weight(sqrtf((right_d / tip_len) * (right_d / tip_len) +
                                                         (across / tip_width) * (across / tip_width)));

            float side_sign = across >= 0.0f ? 1.0f : -1.0f;

            /*
               The elongated center changes shear sense at the middle of the
               finite strike-slip fault.  Use a smooth zero-crossing instead of
               a hard sign flip so the center looks like a continuous direction
               transition rather than two unrelated point sources.
            */
            float center_flip = clampf_gui((along - 0.5f * len_cells) / transition, -1.0f, 1.0f);
            float middle_term = 0.38f * side_sign * center_flip * middle * side_weight;
            float tip_term = 1.15f * side_sign * (right_tip - left_tip);

            float local_depth_km = fault_plane_cell_depth_km(x0, y0, x, y, nx, ny, half_across);
            float ss_amp = ss_amp_raw * strike_slip_tsunami_depth_factor_at_km(local_depth_km);
            float slope_push = strike_slip_slope_push_factor(x, y, ux, uy, nx, ny);
            float h = ss_amp * ss_segment_blend * slope_push * (float)g_strike_slip_sense * (middle_term + tip_term);

            if (fabsf(h) < 0.0005f && ss_amp > 0.0001f) continue;

            float frac = clampf_gui(t, 0.0f, 1.0f);
            float extra_s;
            if (g_bilateral_rupture) {
                float dist_frac = frac < (1.0f - frac) ? frac : (1.0f - frac);
                extra_s = (km_total * dist_frac) / rv;
            } else {
                extra_s = (km_total * frac) / rv;
            }
            int delay = rounded_source_delay_with_cursor(start_cursor_s + extra_s);

            if (ss_segment_blend >= 0.999f) {
                affected += set_source_cell_height_force(hwnd, x, y, h, delay);
            } else {
                affected += set_source_cell_height(hwnd, x, y, h, delay, 1);
            }
        }
    }

    return affected;
}

static int paint_source_line(HWND hwnd, int x0, int y0, int x1, int y1) {
    int affected = 0;

    update_auto_strike_from_motion(x0, y0, x1, y1);

    int orig_x0 = x0;
    int orig_y0 = y0;
    float segment_km = grid_segment_distance_km(x0, y0, x1, y1);
    float start_cursor = g_rupture_delay_cursor_s;

    int dx_abs = abs(x1 - x0);
    int dy_abs = abs(y1 - y0);
    int total_steps = dx_abs > dy_abs ? dx_abs : dy_abs;
    if (total_steps < 1) total_steps = 1;

    int dx = dx_abs;
    int sx = x0 < x1 ? 1 : -1;
    int dy = -dy_abs;
    int sy = y0 < y1 ? 1 : -1;
    int err = dx + dy;
    int step_i = 0;

    for (;;) {
        float frac = (float)step_i / (float)total_steps;
        float extra_s;
        if (g_bilateral_rupture) {
            /*
               During live drawing this is a local segment approximation.
               On mouse-up, new cells from this stroke are rewritten using the
               final start/end points for a cleaner bilateral delay field.
            */
            float local_frac = frac < (1.0f - frac) ? frac : (1.0f - frac);
            extra_s = (segment_km * local_frac) / clampf_gui(g_rupture_velocity_km_s, 0.01f, 20.0f);
        } else {
            extra_s = (segment_km * frac) / clampf_gui(g_rupture_velocity_km_s, 0.01f, 20.0f);
        }

        g_active_paint_delay_override_s = rounded_source_delay_with_cursor(start_cursor + extra_s);
        affected += paint_source_disc(hwnd, x0, y0);
        g_active_paint_delay_override_s = -1;

        if (x0 == x1 && y0 == y1) break;
        int e2 = 2 * err;
        if (e2 >= dy) { err += dy; x0 += sx; }
        if (e2 <= dx) { err += dx; y0 += sy; }
        step_i++;
    }

    /*
       Cursor advancement allows several fault strokes to belong to one rupture
       sequence. Use Reset rupture delay when starting a separate fault system.
    */
    if (!g_bilateral_rupture) {
        g_rupture_delay_cursor_s += segment_km / clampf_gui(g_rupture_velocity_km_s, 0.01f, 20.0f);
    } else {
        g_rupture_delay_cursor_s += (segment_km * 0.5f) / clampf_gui(g_rupture_velocity_km_s, 0.01f, 20.0f);
    }

    (void)orig_x0;
    (void)orig_y0;
    return affected;
}


static int set_mask_override_cell(int grid_x, int grid_y) {
    if (grid_x < 0 || grid_y < 0 || grid_x >= (int)g_grid.width || grid_y >= (int)g_grid.height) return 0;
    size_t idx = tsunami_index(&g_grid, grid_x, grid_y);
    int new_value = (g_mask_paint_tool > 0) ? 1 : ((g_mask_paint_tool == 0) ? 0 : -1);
    int old_value = g_grid.user_override ? g_grid.user_override[idx] : -1;
    if (old_value == new_value) return 0;
    set_land_override(&g_grid, grid_x, grid_y, new_value);
    return 1;
}

static int paint_mask_disc(int center_x, int center_y) {
    int affected = 0;
    int r = g_brush_radius_cells;
    for (int dy = -r; dy <= r; ++dy) {
        for (int dx = -r; dx <= r; ++dx) {
            if (dx * dx + dy * dy > r * r) continue;
            affected += set_mask_override_cell(center_x + dx, center_y + dy);
        }
    }
    return affected;
}

static int paint_mask_line(int x0, int y0, int x1, int y1) {
    int affected = 0;
    int dx = abs(x1 - x0);
    int sx = x0 < x1 ? 1 : -1;
    int dy = -abs(y1 - y0);
    int sy = y0 < y1 ? 1 : -1;
    int err = dx + dy;

    for (;;) {
        affected += paint_mask_disc(x0, y0);
        if (x0 == x1 && y0 == y1) break;
        int e2 = 2 * err;
        if (e2 >= dy) { err += dy; x0 += sx; }
        if (e2 <= dx) { err += dx; y0 += sy; }
    }
    return affected;
}

static int CALLBACK enum_font_family_proc(const LOGFONTW *elfe, const TEXTMETRICW *ntme, DWORD font_type, LPARAM lparam) {
    (void)elfe;
    (void)ntme;
    (void)font_type;
    int *found = (int *)lparam;
    *found = 1;
    return 0;
}

static int font_face_available(const wchar_t *face) {
    HDC hdc = GetDC(NULL);
    if (!hdc) return 0;
    LOGFONTW lf;
    memset(&lf, 0, sizeof(lf));
    lf.lfCharSet = DEFAULT_CHARSET;
    wcsncpy(lf.lfFaceName, face, LF_FACESIZE - 1);
    lf.lfFaceName[LF_FACESIZE - 1] = L'\0';
    int found = 0;
    EnumFontFamiliesExW(hdc, &lf, (FONTENUMPROCW)enum_font_family_proc, (LPARAM)&found, 0);
    ReleaseDC(NULL, hdc);
    return found;
}

static void init_gui_text_font(void) {
    if (g_text_font) return;

    // Try Japanese fonts first, then common Korean/Chinese CJK fonts as fallback.
    // Characters such as 戸, 広, 瀬, and 内 are included in these fonts.
    static const wchar_t *candidates[] = {
        L"Meiryo UI",
        L"Meiryo",
        L"Yu Gothic UI",
        L"Yu Gothic",
        L"MS Gothic",
        L"MS PGothic",
        L"Malgun Gothic",
        L"Microsoft JhengHei UI",
        L"Microsoft YaHei UI",
        L"SimSun",
        NULL
    };

    const wchar_t *chosen = NULL;
    for (int i = 0; candidates[i]; ++i) {
        if (font_face_available(candidates[i])) {
            chosen = candidates[i];
            break;
        }
    }
    if (!chosen) chosen = L"MS Shell Dlg 2";

    wcsncpy(g_text_font_face, chosen, LF_FACESIZE - 1);
    g_text_font_face[LF_FACESIZE - 1] = L'\0';
    g_text_font = CreateFontW(
        -16, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE,
        DEFAULT_CHARSET,
        OUT_DEFAULT_PRECIS,
        CLIP_DEFAULT_PRECIS,
        CLEARTYPE_QUALITY,
        DEFAULT_PITCH | FF_DONTCARE,
        chosen
    );
}

static void font_face_utf8(char *out, size_t out_size) {
    if (!out || out_size == 0) return;
    out[0] = '\0';
    if (!g_text_font_face[0]) {
        snprintf(out, out_size, "default");
        return;
    }
    int n = WideCharToMultiByte(CP_UTF8, 0, g_text_font_face, -1, out, (int)out_size, NULL, NULL);
    if (n <= 0) snprintf(out, out_size, "CJK font");
}

static void draw_text_line(HDC hdc, int x, int *y, const char *text) {
    if (!text) text = "";
    HFONT old_font = NULL;
    if (g_text_font) old_font = (HFONT)SelectObject(hdc, g_text_font);

    WCHAR wbuf[1024];
    int wlen = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text, -1, wbuf, 1024);
    if (wlen > 0) {
        TextOutW(hdc, x, *y, wbuf, wlen - 1);
    } else {
        TextOutA(hdc, x, *y, text, (int)strlen(text));
    }

    if (old_font) SelectObject(hdc, old_font);
    *y += 20;
}

static int text_wrap_space_char(WCHAR ch) {
    return ch == L' ' || ch == L'\t';
}

static int text_wrap_punct_char(WCHAR ch) {
    return ch == L'/' || ch == L'|' || ch == L',' || ch == L';' || ch == L'.';
}

static void draw_text_wrapped(HDC hdc, int x, int *y, int max_width_px, const char *text) {
    if (!text) text = "";
    if (max_width_px <= 32) {
        draw_text_line(hdc, x, y, text);
        return;
    }

    HFONT old_font = NULL;
    if (g_text_font) old_font = (HFONT)SelectObject(hdc, g_text_font);

    WCHAR wbuf[2048];
    int wlen = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text, -1, wbuf, 2048);
    if (wlen <= 0) {
        if (old_font) SelectObject(hdc, old_font);
        draw_text_line(hdc, x, y, text);
        return;
    }

    int len = wlen - 1;
    int start = 0;
    while (start < len) {
        while (start < len && (wbuf[start] == L' ' || wbuf[start] == L'\t')) start++;
        if (start >= len) break;

        int best_end = start + 1;
        int last_break = -1;
        int forced_newline = 0;

        for (int i = start; i < len; ++i) {
            if (wbuf[i] == L'\r' || wbuf[i] == L'\n') {
                best_end = i;
                forced_newline = 1;
                break;
            }

            if (text_wrap_space_char(wbuf[i])) last_break = i;
            else if (text_wrap_punct_char(wbuf[i])) last_break = i + 1;

            SIZE sz = {0, 0};
            int n = i - start + 1;
            if (!GetTextExtentPoint32W(hdc, &wbuf[start], n, &sz)) {
                best_end = i + 1;
                continue;
            }

            if (sz.cx > max_width_px) {
                if (last_break >= start) best_end = last_break;
                else best_end = (i > start) ? i : (i + 1);
                break;
            }

            best_end = i + 1;
        }

        int draw_end = best_end;
        while (draw_end > start && (wbuf[draw_end - 1] == L' ' || wbuf[draw_end - 1] == L'\t')) draw_end--;
        if (draw_end > start) {
            TextOutW(hdc, x, *y, &wbuf[start], draw_end - start);
            *y += 20;
        } else {
            *y += 20;
        }

        if (forced_newline) {
            start = best_end + 1;
            if (start < len && wbuf[best_end] == L'\r' && wbuf[start] == L'\n') start++;
        } else {
            start = best_end;
            while (start < len && text_wrap_space_char(wbuf[start])) start++;
        }
    }

    if (old_font) SelectObject(hdc, old_font);
}

static void format_seconds_gui(int seconds, char *buf, size_t buf_size) {
    if (!buf || buf_size == 0) return;
    if (seconds <= 0) {
        snprintf(buf, buf_size, "not reached");
        return;
    }
    int h = seconds / 3600;
    int m = (seconds % 3600) / 60;
    int s = seconds % 60;
    if (h > 0) snprintf(buf, buf_size, "%dh %02dm %02ds", h, m, s);
    else if (m > 0) snprintf(buf, buf_size, "%dm %02ds", m, s);
    else snprintf(buf, buf_size, "%ds", s);
}

static void draw_panel(HDC hdc, HWND hwnd) {
    RECT rc;
    GetClientRect(hwnd, &rc);
    int x = rc.right - PANEL_W + 12;
    int y = 16;
    char buf[256];

    SetBkMode(hdc, TRANSPARENT);
    SetTextColor(hdc, RGB(235, 235, 235));

    draw_text_line(hdc, x, &y, "PenguinMod Tsunami C GUI");
    y += 6;

    snprintf(buf, sizeof(buf), "%s | %.2f h | %d step/frame", g_running ? "Running" : "Paused", g_time_s / 3600.0, g_steps_per_frame);
    draw_text_line(hdc, x, &y, buf);
    if (g_jesim_transmit_mode) {
        draw_text_line(hdc, x, &y, "Mode: JESIM transmit-only render");
        snprintf(buf, sizeof(buf), "Transmit-only sim cap: %d h", JESIM_TRANSMIT_ONLY_SIM_LIMIT_HOURS);
        draw_text_line(hdc, x, &y, buf);
    }

    snprintf(buf, sizeof(buf), "Slip input %.2fm -> Mw %.2f, M0 %.2e", current_source_input_slip_m(), current_moment_magnitude(), current_seismic_moment_nm());
    draw_text_line(hdc, x, &y, buf);
    snprintf(buf, sizeof(buf), "Fault area %.0f km2, shallow %.0fkm T80=0.15 SS50=0.05 peak %.2fm",
             current_source_fault_area_km2(), g_source_depth_km, current_equivalent_peak_surface_m());
    draw_text_line(hdc, x, &y, buf);

    snprintf(buf, sizeof(buf), "Fault: strike %.0f%s, smooth %.1f, dip %.0f", g_fault_strike_deg, g_fault_stamp_mode ? " stamp" : " auto", g_strike_smoothing_strength, g_fault_dip_deg);
    draw_text_line(hdc, x, &y, buf);
    snprintf(buf, sizeof(buf), "Rupture: %.2f km/s, cursor %.1fs, %s", g_rupture_velocity_km_s, g_rupture_delay_cursor_s, g_bilateral_rupture ? "bilateral" : "unilateral");
    draw_text_line(hdc, x, &y, buf);
    snprintf(buf, sizeof(buf), "Source ramp: %.0fs smooth delay", source_ramp_seconds());
    draw_text_line(hdc, x, &y, buf);
    snprintf(buf, sizeof(buf), "Map top %.2fN, calibrated center scale %.2f km/cell", g_map_top_lat_deg, map_center_ground_km_per_cell());
    draw_text_line(hdc, x, &y, buf);
    snprintf(buf, sizeof(buf), "Sub %.0f%% | strike-slip %.0f%% %s", g_subsidence_ratio * 100.0f, g_strike_slip_mix * 100.0f, strike_slip_sense_name());
    draw_text_line(hdc, x, &y, buf);

    snprintf(buf, sizeof(buf), "Coast absorb: %s, loss %.0f%%", coastal_reflection_absorption_enabled() ? "on" : "off", coastal_reflection_absorption_energy_loss() * 100.0f);
    draw_text_line(hdc, x, &y, buf);

    snprintf(buf, sizeof(buf), "Markers: source %s, coast %s/%s", g_show_source_markers ? "on" : "off", g_show_coast_height_markers ? "on" : "off", g_coast_marker_use_peak ? "peak" : "live");
    draw_text_line(hdc, x, &y, buf);

    snprintf(buf, sizeof(buf), "Time-series stations: %zu", g_ts_rec_count);
    draw_text_line(hdc, x, &y, buf);

    snprintf(buf, sizeof(buf), "Base map: %s | sea-blue %s", g_show_bathymetry ? "bathymetry debug" : "land/sea only", g_invert_landsea_display ? "inverted" : "default");
    draw_text_line(hdc, x, &y, buf);
    snprintf(buf, sizeof(buf), "Wave coeff: %.2fx | alpha %.0f%%", g_wave_display_coeff, g_wave_overlay_alpha * 100.0f);
    draw_text_line(hdc, x, &y, buf);
    snprintf(buf, sizeof(buf), "Wave layer: %s", wave_layer_name());
    draw_text_line(hdc, x, &y, buf);
    if (g_wave_layer_mode != WAVE_LAYER_LIVE) {
        snprintf(buf, sizeof(buf), "Max ocean cell: %.2f m", current_global_max_positive_wave());
        draw_text_line(hdc, x, &y, buf);
    }
    draw_text_line(hdc, x, &y, "Render: cached visual base + wave overlay");

    y += 6;
    SetTextColor(hdc, RGB(170, 225, 255));
    draw_text_line(hdc, x, &y, "Draw source: left click/drag on map");
    draw_text_line(hdc, x, &y, "Hover marker: exact station popup");
    snprintf(buf, sizeof(buf), "View: Ctrl+wheel zoom %.2fx, Ctrl+drag pan, markers scale", g_view_zoom);
    draw_text_line(hdc, x, &y, buf);
    draw_text_line(hdc, x, &y, "Buttons anchored bottom-right as 3x3 block");

    y += 8;
    SetTextColor(hdc, RGB(255, 230, 160));
    draw_text_wrapped(hdc, x, &y, PANEL_W - 24, g_status);
}


static void draw_coast_height_markers(HDC hdc) {
    if (!g_show_coast_height_markers) return;

    /*
       NHK-style zone maximum marker mode:
       instead of coloring every observation point by its own height, all
       observation points in the same forecast zone are colored by the highest
       recorded peak among that zone's stations.
    */
    enum { MAX_ZONE_DISPLAY_ID = 999 };
    float zone_max[MAX_ZONE_DISPLAY_ID + 1];
    for (int z = 0; z <= MAX_ZONE_DISPLAY_ID; ++z) zone_max[z] = 0.0f;

    if (g_coast_marker_use_zone_max) {
        for (uint32_t i = 0; i < g_grid.station_count; ++i) {
            if (!station_is_enabled(&g_grid, i)) continue;
            int zone = station_zone_id_for_display((int)i);
            if (zone < 1 || zone > MAX_ZONE_DISPLAY_ID || zone == 67) continue;

            float h = g_sim.station_max_m ? g_sim.station_max_m[i] : 0.0f;
            if (h > zone_max[zone]) zone_max[zone] = h;
        }
    }

    HPEN outline_pen = CreatePen(PS_SOLID, 1, RGB(0, 0, 0));
    HPEN old_pen = (HPEN)SelectObject(hdc, outline_pen);
    HBRUSH old_brush = (HBRUSH)SelectObject(hdc, GetStockObject(DC_BRUSH));

    for (uint32_t i = 0; i < g_grid.station_count; ++i) {
        if (!station_is_enabled(&g_grid, i)) continue;
        int32_t idx = g_grid.station_index[i];
        if (idx < 0 || (size_t)idx >= g_sim.cells) continue;
        /* Display normal observation points, including non-Japan/display-only
           station_id=67 points, except the artificial far-right boundary line. */
        int32_t sample_idx = g_grid.station_sample_index ? g_grid.station_sample_index[i] : idx;
        if (sample_idx < 0 || (size_t)sample_idx >= g_sim.cells) sample_idx = idx;

        float h = 0.0f;
        if (g_coast_marker_use_zone_max) {
            int zone = station_zone_id_for_display((int)i);
            h = (zone >= 1 && zone <= MAX_ZONE_DISPLAY_ID && zone != 67) ? zone_max[zone] : 0.0f;
        } else {
            h = g_coast_marker_use_peak
                ? g_sim.station_max_m[i]
                : station_observed_height_m(&g_grid, &g_sim, i);
        }

        int visible = 0;
        COLORREF color = original_station_color_for_height(h, &visible);
        if (!visible) continue;

        SetDCBrushColor(hdc, color);
        int gx = idx % (int)g_grid.width;
        int gy = idx / (int)g_grid.width;
        int sx = grid_cell_screen_center_x(gx);
        int sy = grid_cell_screen_center_y(gy);
        int hs = scaled_marker_half_size_px(2);
        Rectangle(hdc, sx - hs, sy - hs, sx + hs + 1, sy + hs + 1);
    }

    SelectObject(hdc, old_brush);
    SelectObject(hdc, old_pen);
    DeleteObject(outline_pen);
}


static void draw_hover_station_highlight(HDC hdc) {
    if (g_hover_station_i < 0) return;
    int gx = 0, gy = 0;
    if (!get_station_info(g_hover_station_i, &gx, &gy, NULL, NULL, NULL, NULL, NULL, NULL, NULL)) return;
    int sx = grid_cell_screen_center_x(gx);
    int sy = grid_cell_screen_center_y(gy);
    int hs = scaled_marker_half_size_px(2) + 4;
    HPEN pen = CreatePen(PS_SOLID, 2, RGB(255, 255, 255));
    HPEN old_pen = (HPEN)SelectObject(hdc, pen);
    HBRUSH old_brush = (HBRUSH)SelectObject(hdc, GetStockObject(NULL_BRUSH));
    Rectangle(hdc, sx - hs, sy - hs, sx + hs + 1, sy + hs + 1);
    SelectObject(hdc, old_brush);
    SelectObject(hdc, old_pen);
    DeleteObject(pen);
}

static void init_station_zone_assignments(void) {
    if (g_station_zone_override_initialized) return;
    for (int i = 0; i < MAX_STATION_ZONE_ASSIGNMENTS; ++i) {
        g_station_zone_override_id[i] = -1;
        g_station_zone_override_name[i][0] = '\0';
    }
    g_station_zone_override_initialized = 1;
}

static int station_zone_id_for_display(int station_i) {
    init_station_zone_assignments();
    if (station_i >= 0 && station_i < MAX_STATION_ZONE_ASSIGNMENTS &&
        g_station_zone_override_id[station_i] > 0) {
        return g_station_zone_override_id[station_i];
    }
    if (station_i >= 0 && station_i < (int)g_grid.station_count && g_grid.station_id) {
        return g_grid.station_id[station_i];
    }
    return 0;
}

static const char *station_zone_name_for_display(int station_i) {
    init_station_zone_assignments();
    if (station_i >= 0 && station_i < MAX_STATION_ZONE_ASSIGNMENTS &&
        g_station_zone_override_id[station_i] > 0 &&
        g_station_zone_override_name[station_i][0] != '\0') {
        return g_station_zone_override_name[station_i];
    }
    return forecast_zone_name_from_id(station_zone_id_for_display(station_i));
}


static void jesim_json_escape(FILE *fp, const char *s) {
    fputc('"', fp);
    if (s) {
        for (const unsigned char *p = (const unsigned char *)s; *p; ++p) {
            unsigned char c = *p;
            if (c == '"' || c == '\\') {
                fputc('\\', fp);
                fputc((int)c, fp);
            } else if (c == '\n') {
                fputs("\\n", fp);
            } else if (c == '\r') {
                fputs("\\r", fp);
            } else if (c == '\t') {
                fputs("\\t", fp);
            } else if (c < 0x20) {
                fprintf(fp, "\\u%04x", (unsigned)c);
            } else {
                fputc((int)c, fp);
            }
        }
    }
    fputc('"', fp);
}

static void jesim_format_abs_time(int sim_s, char *buf, size_t cap) {
    if (!buf || cap == 0) return;
    if (sim_s < 0) sim_s = 0;
    int total_min = (sim_s + 30) / 60;
    int day = 1 + total_min / (24 * 60);
    int minute_of_day = total_min % (24 * 60);
    int hh = minute_of_day / 60;
    int mm = minute_of_day % 60;
    snprintf(buf, cap, "%d日 %02d:%02d", day, hh, mm);
}

static int jesim_ceil_to_5min_s(int s) {
    if (s <= 0) return 0;
    return ((s + 299) / 300) * 300;
}

static int jesim_observation_report_bucket_s(int s) {
    int interval_s = JESIM_OBSERVATION_HEIGHT_UPDATE_INTERVAL_S;
    if (interval_s < 1) interval_s = 300;
    if (s <= 0) return 0;
    return ((s + interval_s - 1) / interval_s) * interval_s;
}

static float jesim_forecast_height_step_m(float h) {
    if (h < 0.05f) return 0.0f;
    if (h <= 0.05f) return 0.05f;
    if (h <= 0.20f) return 0.20f;
    if (h <= 1.0f) return 1.0f;
    if (h <= 3.0f) return 3.0f;
    if (h <= 5.0f) return 5.0f;
    if (h <= 10.0f) return 10.0f;
    return 11.0f; /* sentinel for 10m+ */
}

static void jesim_write_forecast_height_label(FILE *fp, float step_m) {
    if (step_m <= 0.0f) fputs("\"0m\"", fp);
    else if (step_m >= 11.0f) fputs("\"10m+\"", fp);
    else if (step_m == 0.05f) fputs("\"0.05m\"", fp);
    else if (step_m == 0.20f) fputs("\"0.2m\"", fp);
    else fprintf(fp, "\"%.0fm\"", step_m);
}

static int jesim_warning_height_rank(float step_m) {
    /*
       Rank is for Scratch/JESIM list logic.
       0 = release/no warning candidate
       1 = below 1 m but still nonzero observation/advisory-class candidate
       2 = 1 m class
       3 = 3 m class
       4 = 5 m class
       5 = 10 m class
       6 = 10 m+
    */
    if (step_m <= 0.0f) return 0;
    if (step_m < 1.0f) return 1;
    if (step_m < 3.0f) return 2;
    if (step_m < 5.0f) return 3;
    if (step_m < 10.0f) return 4;
    if (step_m < 11.0f) return 5;
    return 6;
}

static const char *jesim_upgrade_decision_from_step(float step_m) {
    /*
       Upgrade is prediction-based. Scratch/JESIM may apply this immediately
       when the message is received, because warnings are upgraded from forecast.
    */
    if (step_m < 1.0f) return "none";
    return "upgrade_or_keep_to_height";
}

static const char *jesim_downgrade_decision_from_step(float step_m) {
    (void)step_m;
    /* Downgrade/release is temporarily suspended.
       Keep the JSON keys for compatibility, but never request release/downgrade. */
    return "none";
}

static int jesim_is_advisory_or_higher_step(float step_m) {
    /* JMA-like advisory-or-higher threshold for update filtering. */
    return step_m >= 0.20f;
}

static int jesim_zone_update_is_significant(int z,
                                            float forecast_step,
                                            float observed_step,
                                            int forecast_rank,
                                            int observed_rank,
                                            int forecast_arrival_5min_s,
                                            int observed_arrival_5min_s) {
    (void)observed_step;
    (void)observed_rank;
    (void)observed_arrival_5min_s;

    if (z < 1 || z > 93 || z == 67) return 0;

    int forecast_active = jesim_is_advisory_or_higher_step(forecast_step);
    int was_forecast_active = g_jesim_last_sent_forecast_active[z];

    /* Upgrade-only mode:
       - send first entry into advisory-or-higher forecast area
       - send increases in forecast warning rank
       - send forecast arrival-time update only while advisory-or-higher forecast exists
       - do NOT send downgrade/release-only updates for now
    */
    if (forecast_active && !was_forecast_active) return 1;

    if (forecast_active &&
        forecast_rank > g_jesim_last_sent_forecast_rank[z]) {
        return 1;
    }

    if (forecast_active &&
        forecast_rank == g_jesim_last_sent_forecast_rank[z] &&
        forecast_arrival_5min_s > 0 &&
        g_jesim_last_sent_forecast_arrival_5min_s[z] != forecast_arrival_5min_s) {
        return 1;
    }

    return 0;
}



static float jesim_observation_round_0p1m(float h) {
    if (h < 0.05f) return 0.0f;
    float r = floorf(h * 10.0f + 0.5f) / 10.0f;
    if (r < 0.1f) r = 0.1f;
    return r;
}

static float jesim_observation_round_signed_0p1m(float h) {
    float a = fabsf(h);
    if (a < 0.05f) return 0.0f;
    float r = floorf(a * 10.0f + 0.5f) / 10.0f;
    if (r < 0.1f) r = 0.1f;
    return h < 0.0f ? -r : r;
}

static int jesim_observation_display_dm_positive(float h) {
    float rounded = jesim_observation_round_0p1m(h);
    if (rounded <= 0.0f) return 0;
    return (int)floorf(rounded * 10.0f + 0.5f);
}

static int jesim_observation_display_dm_signed(float h) {
    float rounded = jesim_observation_round_signed_0p1m(h);
    if (rounded == 0.0f) return 0;
    return (int)(rounded * 10.0f + (rounded > 0.0f ? 0.5f : -0.5f));
}

static float jesim_station_live_signed_m(int station_i) {
    if (station_i < 0 || (uint32_t)station_i >= g_grid.station_count) return 0.0f;
    return station_observed_height_m(&g_grid, &g_sim, (uint32_t)station_i);
}

static float jesim_station_live_positive_m(int station_i) {
    float h = jesim_station_live_signed_m(station_i);
    return h > 0.0f ? h : 0.0f;
}

static float jesim_observation_cell_shoreline_equiv_height(int grid_idx) {
    size_t cells = (size_t)g_grid.width * (size_t)g_grid.height;
    if (grid_idx < 0 || (size_t)grid_idx >= cells) return 0.0f;
    if (is_display_excluded_cell((size_t)grid_idx)) return 0.0f;
    if (g_grid.solid && g_grid.solid[(size_t)grid_idx]) return 0.0f;

    int gx = grid_idx % (int)g_grid.width;
    int gy = grid_idx / (int)g_grid.width;
    int sx = gx + (int)g_sim.outer_pad;
    int sy = gy + (int)g_sim.outer_pad;
    if (sx < 0 || sy < 0 || sx >= (int)g_sim.solver_width || sy >= (int)g_sim.solver_height) return 0.0f;

    size_t p = (size_t)sy * (size_t)g_sim.solver_width + (size_t)sx;
    float normalized_h = g_sim.height_norm ? g_sim.height_norm[p] : 0.0f;
    if (normalized_h >= SCRATCH_LAND_VALUE * 0.5f) return 0.0f;
    return normalized_h * shoaling_factor(STATION_OBSERVATION_DEPTH_M);
}

static float jesim_observation_cell_live_signed_m(int grid_idx) {
    if (grid_idx < 0) return 0.0f;
    int cx = grid_idx % (int)g_grid.width;
    int cy = grid_idx / (int)g_grid.width;
    const int rmax = JESIM_OBSERVATION_LOCAL_SAMPLE_RADIUS_CELLS;
    float best_positive = 0.0f;
    float best_negative = 0.0f;
    int found_negative = 0;

    for (int dy = -rmax; dy <= rmax; ++dy) {
        for (int dx = -rmax; dx <= rmax; ++dx) {
            if (dx * dx + dy * dy > rmax * rmax) continue;
            int gx = cx + dx;
            int gy = cy + dy;
            if (gx < 0 || gy < 0 || gx >= (int)g_grid.width || gy >= (int)g_grid.height) continue;
            float h = jesim_observation_cell_shoreline_equiv_height(gy * (int)g_grid.width + gx);
            if (h > best_positive) {
                best_positive = h;
            } else if (h < 0.0f) {
                if (!found_negative || h < best_negative) {
                    best_negative = h;
                    found_negative = 1;
                }
            }
        }
    }

    if (best_positive > 0.0f) return best_positive;
    if (found_negative) return best_negative;
    return 0.0f;
}

static float jesim_observation_live_signed_m(int oi) {
    if (oi < 0 || oi >= g_jesim_observation_point_count) return 0.0f;
    JesimObservationPoint *op = &g_jesim_observation_points[oi];

    /*
       JESIM observation reports must be tied to the observation point itself,
       not to the nearest legacy station slot.  The legacy station sampler uses
       station_sample_index plus a small max-positive offshore neighborhood; on
       curved coasts/bays that can borrow a value from the next coastal cell and
       make Scratch look one or two points out of phase.

       Therefore read the live value from the official/virtual observation cell
       first.  The nearest legacy station number is kept only as fallback/debug
       metadata.
    */
    if (op->mapped_grid_index >= 0) {
        return jesim_observation_cell_live_signed_m(op->mapped_grid_index);
    }

    int station_no = op->mapped_station_no;
    if (station_no > 0) {
        return jesim_station_live_signed_m(station_no - 1);
    }
    return 0.0f;
}

static float jesim_observation_peak_positive_m(int oi, float live_now_m) {
    if (oi < 0 || oi >= JESIM_MAX_OBSERVATION_POINTS) return 0.0f;
    float positive = live_now_m > 0.0f ? live_now_m : 0.0f;

    /*
       Do not import the legacy station peak when this observation has its own
       mapped_grid_index.  station_max_m can come from the neighboring sampler
       radius and is the main cause of "height arrived before this gauge/name"
       artifacts in observation-point websocket reports.  Use it only as a last
       fallback for observations that truly have no local mapped cell.
    */
    if (oi >= 0 && oi < g_jesim_observation_point_count) {
        JesimObservationPoint *op = &g_jesim_observation_points[oi];
        int si = op->mapped_station_no - 1;
        if (op->mapped_grid_index < 0 &&
            si >= 0 && (uint32_t)si < g_grid.station_count && g_sim.station_max_m) {
            float station_peak = g_sim.station_max_m[si];
            if (station_peak > positive) positive = station_peak;
        }
    }

    if (positive > g_jesim_observation_peak_m[oi]) {
        g_jesim_observation_peak_m[oi] = positive;
    }
    return g_jesim_observation_peak_m[oi];
}
static int jesim_observation_zone_id_for_export(int oi) {
    if (oi < 0 || oi >= g_jesim_observation_point_count) return 0;
    JesimObservationPoint *op = &g_jesim_observation_points[oi];
    if (op->preferred_zone_id > 0) return op->preferred_zone_id;

    /* Prefer the observation/display cell's zone over the nearest legacy
       station zone.  The legacy station can be the next bay/neighboring coast
       when the official 2025 point is between old station-chain cells. */
    if (op->mapped_grid_index >= 0) {
        int z = jesim_wave_zone_for_cell((size_t)op->mapped_grid_index);
        if (z > 0 && z != 67) return z;
    }
    if (op->mapped_station_no > 0) return station_zone_id_for_display(op->mapped_station_no - 1);
    return 0;
}

static void jesim_ensure_obs_memory(void) {
    if (g_jesim_prev_obs_m) return;
    if (g_grid.station_count == 0) return;
    g_jesim_prev_obs_m = (float *)calloc(g_grid.station_count, sizeof(float));
    g_jesim_prev_obs_time_s = -1;
}


static void jesim_clear_outbox(const char *reason) {
    (void)reason;
    if (!g_jesim_export_enabled) return;

    FILE *fp = fopen(jesim_outbox_path(), "wb");
    if (!fp) return;
    fclose(fp);
}

static void jesim_reset_export_state(void) {
    jesim_clear_outbox("simulation reset or source/mask reload");
    g_jesim_last_10min_bucket = -1;
    g_jesim_sent_metadata = 0;
    g_jesim_sent_test_snapshot = 0;
    g_jesim_sent_6h = 0;
    g_jesim_sent_12h = 0;
    g_jesim_sent_24h = 0;
    g_jesim_next_warning_check_h = 6;
    memset(g_jesim_last_sent_forecast_rank, 0, sizeof(g_jesim_last_sent_forecast_rank));
    memset(g_jesim_last_sent_observed_rank, 0, sizeof(g_jesim_last_sent_observed_rank));
    memset(g_jesim_last_sent_forecast_active, 0, sizeof(g_jesim_last_sent_forecast_active));
    memset(g_jesim_last_sent_observed_active, 0, sizeof(g_jesim_last_sent_observed_active));
    memset(g_jesim_last_sent_forecast_arrival_5min_s, 0, sizeof(g_jesim_last_sent_forecast_arrival_5min_s));
    memset(g_jesim_last_sent_observed_arrival_5min_s, 0, sizeof(g_jesim_last_sent_observed_arrival_5min_s));
    memset(g_jesim_observation_event_queue, 0, sizeof(g_jesim_observation_event_queue));
    g_jesim_observation_event_count = 0;
    memset(g_jesim_observation_first_queued, 0, sizeof(g_jesim_observation_first_queued));
    memset(g_jesim_observation_last_queued_live_dm, 0, sizeof(g_jesim_observation_last_queued_live_dm));
    memset(g_jesim_observation_last_queued_peak_dm, 0, sizeof(g_jesim_observation_last_queued_peak_dm));
    memset(g_jesim_observation_peak_m, 0, sizeof(g_jesim_observation_peak_m));
    memset(g_jesim_observation_arrival_s, 0, sizeof(g_jesim_observation_arrival_s));
    memset(g_jesim_prev_obs_point_m, 0, sizeof(g_jesim_prev_obs_point_m));
    jesim_free_wave_zone_map();
    g_jesim_realtime_active = 0;
    g_jesim_realtime_start_ms = 0;
    g_jesim_realtime_qpc_valid = 0;
    g_jesim_last_realtime_5min_bucket = -1;
    g_jesim_prev_obs_time_s = -1;
    if (g_jesim_prev_obs_m && g_grid.station_count > 0) {
        memset(g_jesim_prev_obs_m, 0, g_grid.station_count * sizeof(float));
    }
}

static void jesim_free_export_state(void) {
    free(g_jesim_prev_obs_m);
    g_jesim_prev_obs_m = NULL;
    g_jesim_prev_obs_time_s = -1;
}


static int jesim_realtime_elapsed_s(void) {
    if (!g_jesim_realtime_active || g_jesim_realtime_start_ms == 0) return 0;

    if (g_jesim_realtime_qpc_valid && g_jesim_realtime_qpc_freq.QuadPart > 0) {
        LARGE_INTEGER now_qpc;
        if (QueryPerformanceCounter(&now_qpc)) {
            LONGLONG ticks = now_qpc.QuadPart - g_jesim_realtime_start_qpc.QuadPart;
            if (ticks <= 0) return 0;
            long double sec = (long double)ticks / (long double)g_jesim_realtime_qpc_freq.QuadPart;
            if (sec > 2147483000.0L) return 2147483000;
            return (int)sec;
        }
    }

    /* Fallback only: QPC should be available on modern Windows, but keep a
       monotonic millisecond clock path so the program still runs everywhere. */
    ULONGLONG now = GetTickCount64();
    if (now <= g_jesim_realtime_start_ms) return 0;
    ULONGLONG ms = now - g_jesim_realtime_start_ms;
    if (ms > 2147483000ULL * 1000ULL) return 2147483000;
    return (int)(ms / 1000ULL);
}

static int jesim_realtime_speed_factor(void) {
    return JESIM_OBSERVATION_REALTIME_SPEED_FACTOR_FIXED;
}

static int jesim_realtime_sync_s(void) {
    long long s = (long long)g_jesim_calc_start_delay_s +
                  (long long)jesim_realtime_elapsed_s();
    if (s < 0) return 0;
    if (s > 2147483000LL) return 2147483000;
    return (int)s;
}

static int jesim_realtime_event_due_s(int model_event_s) {
    if (model_event_s <= 0) return 0;
    if (!g_jesim_realtime_active || g_jesim_realtime_start_ms == 0) return 0;
    return jesim_realtime_sync_s() >= model_event_s;
}

static void jesim_start_realtime_warning_clock(void) {
    if (g_jesim_realtime_active) return;
    ULONGLONG now = GetTickCount64();
    g_jesim_realtime_active = 1;
    g_jesim_realtime_start_ms = now;
    g_jesim_realtime_qpc_valid = 0;
    if (QueryPerformanceFrequency(&g_jesim_realtime_qpc_freq) &&
        QueryPerformanceCounter(&g_jesim_realtime_start_qpc) &&
        g_jesim_realtime_qpc_freq.QuadPart > 0) {
        g_jesim_realtime_qpc_valid = 1;
    }
    g_jesim_last_realtime_5min_bucket = g_jesim_calc_start_delay_s / 300;
}


static int jesim_warning_arrival_s_from_model_s(int model_arrival_s) {
    /* No ETA compression.
       JESIM warning-system time uses the same seconds as the internal model
       arrival time.  If the model says 10800 s, JESIM must wait 10800 s before
       treating the tsunami as arrived. */
    if (model_arrival_s <= 0) return 0;
    return model_arrival_s;
}

static int jesim_warning_ceil_to_5min_s(int s) {
    if (s <= 0) return 0;
    return ((s + 299) / 300) * 300;
}

static const char *jesim_arrival_status_label(int arrival_s, int issue_s) {
    /* arrival_s and issue_s are both JESIM warning-system real seconds. */
    if (arrival_s <= 0) return "未定";

    int dt = arrival_s - issue_s;
    if (dt > 10 * 60) return "到達予想";
    if (dt > 0) return "まもなく到達";

    int after = -dt;
    if (after <= 20 * 60) return "到達中";
    return "到達と推定";
}



static void jesim_trim_ascii(char *s) {
    if (!s) return;
    char *p = s;
    while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n') ++p;
    if (p != s) memmove(s, p, strlen(p) + 1);
    size_t n = strlen(s);
    while (n > 0 && (s[n - 1] == ' ' || s[n - 1] == '\t' || s[n - 1] == '\r' || s[n - 1] == '\n')) s[--n] = '\0';
}

static int jesim_observation_default_zone_id(const char *id) {
    if (!id || !*id) return 0;

    /*
       Geo lat/lon is useful for display, but the bundled station grid is the
       authoritative visual/JESIM coordinate set.  A pure nearest-latlon lookup
       can jump to the wrong station chain around Tohoku because the map artwork
       and the Mercator helper do not align perfectly there (Iwate gauges were
       being mapped into the Hokkaido-Pacific station chain, while Miyagi and
       Fukushima appeared one zone early).  Use each known tide gauge's official
       JMA forecast zone as a first-pass constraint, then fall back to unrestricted
       nearest-station search only if that zone is absent.
    */
    if (_stricmp(id, "ABASHIRI") == 0) return 1;
    if (_stricmp(id, "KUSHIRO") == 0) return 2;
    if (_stricmp(id, "NEMURO") == 0) return 2;
    if (_stricmp(id, "HAKODATE") == 0) return 4;
    if (_stricmp(id, "WAKKANAI") == 0) return 5;
    if (_stricmp(id, "AOMORI") == 0) return 7;
    if (_stricmp(id, "HACHINOHE") == 0) return 9;
    if (_stricmp(id, "MIYAKO") == 0) return 10;
    if (_stricmp(id, "KAMAISHI") == 0) return 10;
    if (_stricmp(id, "OFUNATO") == 0) return 10;
    if (_stricmp(id, "AYUKAWA") == 0) return 11;
    if (_stricmp(id, "SENDAI") == 0) return 11;
    if (_stricmp(id, "ONAHAMA") == 0) return 12;
    if (_stricmp(id, "CHOSHI") == 0) return 16;
    if (_stricmp(id, "TOKYO") == 0) return 18;
    if (_stricmp(id, "YOKOHAMA") == 0) return 18;
    if (_stricmp(id, "SHIMIZU") == 0) return 22;
    if (_stricmp(id, "OMAEZAKI") == 0) return 22;
    if (_stricmp(id, "NAGOYA") == 0) return 24;
    if (_stricmp(id, "OSAKA") == 0) return 36;
    if (_stricmp(id, "KOBE") == 0) return 34;
    if (_stricmp(id, "HIROSHIMA") == 0) return 42;
    if (_stricmp(id, "UWAJIMA") == 0) return 45;
    if (_stricmp(id, "KOCHI") == 0) return 47;
    if (_stricmp(id, "KAGOSHIMA") == 0) return 61;
    if (_stricmp(id, "NAZE") == 0) return 63;
    if (_stricmp(id, "NAHA") == 0) return 64;
    if (_stricmp(id, "ISHIGAKI") == 0) return 66;
    if (_stricmp(id, "IZU_OSHIMA_OKADA") == 0) return 19;
    if (_stricmp(id, "TOSHIMA") == 0) return 19;
    if (_stricmp(id, "NIIJIMA") == 0) return 19;
    if (_stricmp(id, "SHIKINEJIMA") == 0) return 19;
    if (_stricmp(id, "KOZUSHIMA") == 0) return 19;
    if (_stricmp(id, "MIYAKEJIMA_AKO") == 0) return 19;
    if (_stricmp(id, "MIKURAJIMA") == 0) return 19;
    if (_stricmp(id, "HACHIJOJIMA_YAENE") == 0) return 19;
    if (_stricmp(id, "AOGASHIMA") == 0) return 19;

    /* Official 2025 additions: give them an explicit JMA forecast-zone
       constraint too.  Without this, OFF25_xxx points fall back to a pure
       nearest-station search; around Tohoku/Kanto the Mercator/artwork offset
       can bind Miyagi/Fukushima gauges to the wrong legacy station chain. */
    if (_stricmp(id, "OFF25_018") == 0) return 9;   /* むつ小川原: 青森県太平洋沿岸 */
    if (_stricmp(id, "OFF25_019") == 0) return 10;  /* 久慈: 岩手県 */
    if (_stricmp(id, "OFF25_020") == 0) return 11;  /* 石巻: 宮城県 */
    if (_stricmp(id, "OFF25_029") == 0) return 15;  /* 鹿島: 茨城県 */
    if (_stricmp(id, "OFF25_060") == 0) return 7;   /* 浅虫: 陸奥湾 */
    if (_stricmp(id, "OFF25_061") == 0) return 13;  /* 男鹿: 秋田県 */
    if (_stricmp(id, "OFF25_062") == 0) return 14;  /* 鼠ヶ関: 山形県 */
    if (_stricmp(id, "OFF25_063") == 0) return 12;  /* 相馬: 福島県 */
    if (_stricmp(id, "OFF25_081") == 0) return 14;  /* 飛島: 山形県 */
    if (_stricmp(id, "OFF25_084") == 0) return 12;  /* いわき市小名浜: 福島県 */
    if (_stricmp(id, "OFF25_085") == 0) return 8;   /* 深浦: 青森県日本海沿岸 */
    if (_stricmp(id, "OFF25_113") == 0) return 15;  /* 大洗: 茨城県 */
    if (_stricmp(id, "OFF25_117") == 0) return 9;   /* 八戸港: 青森県太平洋沿岸 */
    if (_stricmp(id, "OFF25_118") == 0) return 8;   /* 竜飛: 青森県日本海沿岸 */
    return 0;
}

static void jesim_add_observation_point(const char *id, const char *name, const char *agency,
                                        float lat_deg, float lon_deg) {
    if (g_jesim_observation_point_count >= JESIM_MAX_OBSERVATION_POINTS) return;
    JesimObservationPoint *p = &g_jesim_observation_points[g_jesim_observation_point_count++];
    memset(p, 0, sizeof(*p));
    snprintf(p->id, sizeof(p->id), "%s", id && *id ? id : "OBS");
    snprintf(p->name, sizeof(p->name), "%s", name && *name ? name : p->id);
    snprintf(p->agency, sizeof(p->agency), "%s", agency && *agency ? agency : "JMA");
    p->lat_deg = lat_deg;
    p->lon_deg = lon_deg;
    p->preferred_zone_id = jesim_observation_default_zone_id(p->id);
    p->csv_station_no = 0;
    p->csv_station_no_duplicate = 0;
    p->mapped_station_no = 0;
    p->mapped_grid_index = -1;
    p->mapped_grid_x = -1;
    p->mapped_grid_y = -1;
    p->mapped_is_virtual_coast = 0;
    p->mapped_depth_m = 0.0f;
}

static int jesim_find_nearest_station_for_latlon(float lat_deg, float lon_deg, int preferred_zone_id) {
    if (!g_grid.station_index || g_grid.station_count == 0) return 0;
    float gx = grid_x_from_lon_deg_mercator(lon_deg);
    float gy = grid_y_from_lat_deg_mercator(lat_deg);

    /*
       Observation points from the 2025 official table are mapped onto the
       already-existing coastline station chain.  Do not create a new gauge cell
       for them.  Prefer the requested forecast zone when one is known, then the
       nearest non-outside station, and only as a final fallback allow zone 67
       outside/edge stations.
    */
    int pass_count = preferred_zone_id > 0 ? 3 : 2;
    for (int pass = 0; pass < pass_count; ++pass) {
        float best_d2 = 1.0e30f;
        int best_station_no = 0;
        for (uint32_t i = 0; i < g_grid.station_count; ++i) {
            if (!station_is_enabled(&g_grid, i)) continue;
            int zone_id = g_grid.station_id ? g_grid.station_id[i] : 0;
            if (preferred_zone_id > 0 && pass == 0) {
                if (zone_id != preferred_zone_id) continue;
            } else {
                int no_outside_pass = (preferred_zone_id > 0) ? 1 : 0;
                if (pass == no_outside_pass && zone_id == 67) continue;
            }
            int32_t idx = g_grid.station_index[i];
            if (idx < 0) continue;
            int x = idx % (int)g_grid.width;
            int y = idx / (int)g_grid.width;
            float dx = (float)x - gx;
            float dy = (float)y - gy;
            float d2 = dx * dx + dy * dy;
            if (d2 < best_d2) {
                best_d2 = d2;
                best_station_no = (int)i + 1;
            }
        }
        if (best_station_no > 0) return best_station_no;
    }
    return 0;
}

#define JESIM_OBS_SHALLOW_SCAN_R1_CELLS 8
#define JESIM_OBS_SHALLOW_SCAN_R2_CELLS 18
#define JESIM_OBS_SHALLOW_SCAN_R3_CELLS 34

static int jesim_observation_cell_is_ocean_sample(int idx) {
    size_t cells = (size_t)g_grid.width * (size_t)g_grid.height;
    if (idx < 0 || (size_t)idx >= cells) return 0;
    if (is_display_excluded_cell((size_t)idx)) return 0;
    if (g_grid.solid && g_grid.solid[(size_t)idx]) return 0;
    float d = 0.0f;
    if (g_grid.effective_depth_m) d = g_grid.effective_depth_m[(size_t)idx];
    else if (g_grid.depth_m) d = g_grid.depth_m[(size_t)idx];
    return d > 0.0f;
}

static int jesim_find_shallow_coastal_cell_for_latlon(float lat_deg, float lon_deg,
                                                      int preferred_zone_id,
                                                      int *out_idx, float *out_depth_m) {
    if (!g_grid.depth_m || g_grid.width == 0 || g_grid.height == 0) return 0;

    float gx_f = grid_x_from_lon_deg_mercator(lon_deg);
    float gy_f = grid_y_from_lat_deg_mercator(lat_deg);
    int gx0 = (int)floorf(gx_f + 0.5f);
    int gy0 = (int)floorf(gy_f + 0.5f);

    const int radii[3] = { JESIM_OBS_SHALLOW_SCAN_R1_CELLS,
                           JESIM_OBS_SHALLOW_SCAN_R2_CELLS,
                           JESIM_OBS_SHALLOW_SCAN_R3_CELLS };

    for (int pass = 0; pass < 3; ++pass) {
        int r = radii[pass];
        float best_score = 1.0e30f;
        float best_depth = 0.0f;
        int best_idx = -1;

        for (int dy = -r; dy <= r; ++dy) {
            int y = gy0 + dy;
            if (y < 0 || y >= (int)g_grid.height) continue;
            for (int dx = -r; dx <= r; ++dx) {
                int x = gx0 + dx;
                if (x < 0 || x >= (int)g_grid.width) continue;
                int d2i = dx * dx + dy * dy;
                if (d2i > r * r) continue;

                int idx = y * (int)g_grid.width + x;
                if (!jesim_observation_cell_is_ocean_sample(idx)) continue;

                float depth = g_grid.effective_depth_m ? g_grid.effective_depth_m[(size_t)idx] : g_grid.depth_m[(size_t)idx];
                if (depth <= 0.0f) continue;

                /* Prefer the shallowest real water cell near the gauge.  A small
                   distance penalty prevents a wide scan from jumping from a small
                   island to a remote mainland bay, while still following depth
                   distribution when the island itself is not represented as land. */
                float dx_f = (float)x - gx_f;
                float dy_f = (float)y - gy_f;
                float d2 = dx_f * dx_f + dy_f * dy_f;
                float score = depth + 0.18f * d2;

                if (score < best_score) {
                    best_score = score;
                    best_depth = depth;
                    best_idx = idx;
                }
            }
        }

        if (best_idx >= 0) {
            if (out_idx) *out_idx = best_idx;
            if (out_depth_m) *out_depth_m = best_depth;
            return 1;
        }
        (void)preferred_zone_id;
    }

    return 0;
}

static int jesim_observation_point_wants_virtual_station(const JesimObservationPoint *op);

static int jesim_observation_projected_grid_index_from_latlon(float lat_deg, float lon_deg) {
    if (g_grid.width <= 0 || g_grid.height <= 0) return -1;
    int gx = (int)floorf(grid_x_from_lon_deg_mercator(lon_deg) + 0.5f);
    int gy = (int)floorf(grid_y_from_lat_deg_mercator(lat_deg) + 0.5f);
    if (gx < 0 || gy < 0 || gx >= (int)g_grid.width || gy >= (int)g_grid.height) return -1;
    return gy * (int)g_grid.width + gx;
}

static int jesim_display_xy_from_latlon_values(float lat_deg, float lon_deg, int *out_x, int *out_y) {
    if (!out_x || !out_y || g_grid.width <= 0 || g_grid.height <= 0) return 0;
    int gx = (int)floorf(grid_x_from_lon_deg_mercator(lon_deg) + 0.5f);
    int gy = (int)floorf(grid_y_from_lat_deg_mercator(lat_deg) + 0.5f);
    if (gx < 0 || gy < 0 || gx >= (int)g_grid.width || gy >= (int)g_grid.height) return 0;
    *out_x = gx;
    *out_y = gy;
    return 1;
}

static int jesim_observation_display_xy_from_latlon(const JesimObservationPoint *op, int *out_x, int *out_y) {
    if (!op) return 0;
    return jesim_display_xy_from_latlon_values(op->lat_deg, op->lon_deg, out_x, out_y);
}

static void jesim_apply_csv_latlon_display_coordinate(JesimObservationPoint *op) {
    if (!op) return;

    int gx = -1;
    int gy = -1;
    if (!jesim_observation_display_xy_from_latlon(op, &gx, &gy)) return;

    /* v14 clean rebuild:
       All marker/display coordinates come from the observation CSV latitude and
       longitude.  Do not touch mapped_grid_index or mapped_station_no here;
       those remain the existing station/local sample used for height/arrival. */
    op->mapped_grid_x = gx;
    op->mapped_grid_y = gy;
}

static void jesim_mark_duplicate_csv_station_numbers(void) {
    for (int i = 0; i < g_jesim_observation_point_count; ++i) {
        g_jesim_observation_points[i].csv_station_no_duplicate = 0;
    }
    for (int i = 0; i < g_jesim_observation_point_count; ++i) {
        int s = g_jesim_observation_points[i].csv_station_no;
        if (s <= 0) continue;
        for (int j = i + 1; j < g_jesim_observation_point_count; ++j) {
            if (g_jesim_observation_points[j].csv_station_no == s) {
                g_jesim_observation_points[i].csv_station_no_duplicate = 1;
                g_jesim_observation_points[j].csv_station_no_duplicate = 1;
            }
        }
    }
}

static void jesim_map_observation_points_to_stations(void) {
    jesim_mark_duplicate_csv_station_numbers();

    for (int i = 0; i < g_jesim_observation_point_count; ++i) {
        JesimObservationPoint *op = &g_jesim_observation_points[i];
        if (op->csv_station_no > 0) {
            /* v15: station.txt is authoritative.  Keep the exact supplied
               1-based simulator station number instead of re-running nearest
               station search, because nearest search caused coast-parallel
               slips and duplicate station aliases. */
            op->mapped_station_no = op->csv_station_no;
        } else {
            op->mapped_station_no =
                jesim_find_nearest_station_for_latlon(op->lat_deg,
                                                      op->lon_deg,
                                                      op->preferred_zone_id);
        }

        op->mapped_grid_index = -1;
        op->mapped_grid_x = -1;
        op->mapped_grid_y = -1;
        op->mapped_depth_m = 0.0f;
        op->mapped_is_virtual_coast = 0;

        int projected_idx = jesim_observation_projected_grid_index_from_latlon(op->lat_deg, op->lon_deg);

        if (jesim_observation_point_wants_virtual_station(op)) {
            int idx = -1;
            float depth = 0.0f;
            if (jesim_find_shallow_coastal_cell_for_latlon(op->lat_deg, op->lon_deg,
                                                           op->preferred_zone_id,
                                                           &idx, &depth)) {
                op->mapped_grid_index = idx;
                op->mapped_grid_x = idx % (int)g_grid.width;
                op->mapped_grid_y = idx / (int)g_grid.width;
                op->mapped_depth_m = depth;
                op->mapped_is_virtual_coast = 1;
            }
        } else if (op->mapped_station_no > 0) {
            int si = op->mapped_station_no - 1;
            size_t cells = (size_t)g_grid.width * (size_t)g_grid.height;

            /*
               v5 station-coordinate fix:
               mapped_grid_x/y must be the grid coordinate of the already chosen
               simulator station id (mapped_station_no).  The station id mapping
               itself was correct; the previous build put the observation's raw
               projected lat/lon cell into mapped_grid_x/y, so many gauges were
               drawn at a different cell than the station slot that owned their
               value/arrival.  This looked like a coast-parallel shift in
               Scratch/PenguinMod.

               Use g_grid.station_index[station_no-1] for mapped/grid/display
               coordinates.  Keep station_sample_index separate for the
               sample_grid_x/y debug fields and depth lookup.
            */
            if ((uint32_t)si < g_grid.station_count && g_grid.station_index) {
                int marker_idx = g_grid.station_index[si];
                if (marker_idx >= 0 && (size_t)marker_idx < cells) {
                    op->mapped_grid_index = marker_idx;
                    op->mapped_grid_x = marker_idx % (int)g_grid.width;
                    op->mapped_grid_y = marker_idx / (int)g_grid.width;
                }

                int sample_idx = g_grid.station_sample_index ? g_grid.station_sample_index[si] : marker_idx;
                int depth_idx = sample_idx >= 0 ? sample_idx : marker_idx;
                if (depth_idx >= 0 && (size_t)depth_idx < cells) {
                    if (g_grid.effective_depth_m) op->mapped_depth_m = g_grid.effective_depth_m[(size_t)depth_idx];
                    else if (g_grid.depth_m) op->mapped_depth_m = g_grid.depth_m[(size_t)depth_idx];
                }
            }

            if (op->mapped_grid_index < 0 && projected_idx >= 0 && (size_t)projected_idx < cells) {
                /* Last-resort fallback only when station_index is unavailable or
                   when csv_station_no references a runtime virtual station that
                   is not present yet in the base tsunami_data.bin. */
                op->mapped_grid_index = projected_idx;
                op->mapped_grid_x = projected_idx % (int)g_grid.width;
                op->mapped_grid_y = projected_idx / (int)g_grid.width;
            }
        } else if (projected_idx >= 0) {
            op->mapped_grid_index = projected_idx;
            op->mapped_grid_x = projected_idx % (int)g_grid.width;
            op->mapped_grid_y = projected_idx / (int)g_grid.width;
        }

        if (JESIM_OBS_DISPLAY_FROM_LATLON_FOR_ALL ||
            (JESIM_OBS_DISPLAY_FROM_LATLON_FOR_DUPLICATE_STATION && op->csv_station_no_duplicate)) {
            jesim_apply_csv_latlon_display_coordinate(op);
        }

        /* v16: no per-gauge manual visual override.
           清水/清水港 now follows the same station_index rule as other
           non-duplicate gauges, so station_no 2076 displays at the actual
           simulator station marker instead of the old hard-coded x=479,y=250. */
    }
}


static int jesim_observation_point_wants_virtual_station(const JesimObservationPoint *op) {
    if (!op) return 0;
    if (op->preferred_zone_id == 19) return 1; /* 伊豆諸島 */
    if (strstr(op->agency, "VIRTUAL") != NULL) return 1;
    return 0;
}

static int jesim_find_existing_station_at_cell_zone(int grid_idx, int zone_id) {
    if (!g_grid.station_index || !g_grid.station_id || grid_idx < 0) return 0;
    for (uint32_t i = 0; i < g_grid.station_count; ++i) {
        if (!station_is_enabled(&g_grid, i)) continue;
        if (g_grid.station_index[i] == grid_idx && g_grid.station_id[i] == zone_id) {
            return (int)i + 1;
        }
    }
    return 0;
}

static int jesim_append_virtual_coast_station_to_grid(GridData *g, int grid_idx, int zone_id) {
    if (!g || !g->station_index || !g->station_sample_index || !g->station_id || !g->station_enabled) return 0;
    if (grid_idx < 0) return 0;
    size_t cells = (size_t)g->width * (size_t)g->height;
    if ((size_t)grid_idx >= cells) return 0;
    if (zone_id <= 0) return 0;
    if (g->solid && g->solid[(size_t)grid_idx]) return 0;

    uint32_t old_n = g->station_count;
    uint32_t new_n = old_n + 1u;

    int32_t *new_index = (int32_t *)tsunami_xcalloc(new_n, sizeof(int32_t));
    int32_t *new_sample = (int32_t *)tsunami_xcalloc(new_n, sizeof(int32_t));
    int32_t *new_id = (int32_t *)tsunami_xcalloc(new_n, sizeof(int32_t));
    uint8_t *new_enabled = (uint8_t *)tsunami_xcalloc(new_n, sizeof(uint8_t));

    if (old_n > 0) {
        memcpy(new_index, g->station_index, old_n * sizeof(int32_t));
        memcpy(new_sample, g->station_sample_index, old_n * sizeof(int32_t));
        memcpy(new_id, g->station_id, old_n * sizeof(int32_t));
        memcpy(new_enabled, g->station_enabled, old_n * sizeof(uint8_t));
    }

    new_index[old_n] = grid_idx;
    new_sample[old_n] = grid_idx;
    new_id[old_n] = zone_id;
    /* 2 = enabled virtual observation station.  The core land-mask rebuild
       skips value 2 as a coastline barrier, so this observes water without
       adding or reshaping land. */
    new_enabled[old_n] = 2u;

    free(g->station_index);
    free(g->station_sample_index);
    free(g->station_id);
    free(g->station_enabled);
    g->station_index = new_index;
    g->station_sample_index = new_sample;
    g->station_id = new_id;
    g->station_enabled = new_enabled;
    g->station_count = new_n;
    return (int)new_n;
}

static int jesim_add_virtual_coast_observation_stations(GridData *g) {
    if (!g || !g->station_index || !g->station_id) return 0;
    jesim_load_observation_points();

    int added = 0;
    for (int oi = 0; oi < g_jesim_observation_point_count; ++oi) {
        JesimObservationPoint *op = &g_jesim_observation_points[oi];
        if (!jesim_observation_point_wants_virtual_station(op)) continue;
        if (op->mapped_grid_index < 0) continue;
        int z = op->preferred_zone_id > 0 ? op->preferred_zone_id : jesim_observation_zone_id_for_export(oi);
        if (z <= 0) continue;

        /* Keep one virtual station per observation point even when several small
           islands collapse onto the same shallow bathymetry cell.  Scratch-side
           assets may address the 9 Izu gauges as separate coastline stations,
           so do not de-duplicate by grid cell here. */
        int station_no = jesim_append_virtual_coast_station_to_grid(g, op->mapped_grid_index, z);
        if (station_no > 0) {
            added++;
            op->mapped_station_no = station_no;
            op->mapped_is_virtual_coast = 1;
        }
    }
    return added;
}

static void jesim_load_builtin_observation_points(void) {
    jesim_add_observation_point("WAKKANAI", "稚内", "JMA", 45.4167f, 141.6833f);
    jesim_add_observation_point("ABASHIRI", "網走", "JMA", 44.0167f, 144.2833f);
    jesim_add_observation_point("KUSHIRO", "釧路", "JMA", 42.9833f, 144.3667f);
    jesim_add_observation_point("NEMURO", "根室", "JMA", 43.3333f, 145.5833f);
    jesim_add_observation_point("HAKODATE", "函館", "JMA", 41.7667f, 140.7167f);
    jesim_add_observation_point("AOMORI", "青森", "JMA", 40.8333f, 140.7333f);
    jesim_add_observation_point("HACHINOHE", "八戸", "JMA", 40.5333f, 141.5333f);
    jesim_add_observation_point("MIYAKO", "宮古", "JMA", 39.6333f, 141.9667f);
    jesim_add_observation_point("KAMAISHI", "釜石", "JMA", 39.2667f, 141.8833f);
    jesim_add_observation_point("OFUNATO", "大船渡", "JMA", 39.0667f, 141.7167f);
    jesim_add_observation_point("AYUKAWA", "鮎川", "JMA", 38.3000f, 141.5000f);
    jesim_add_observation_point("SENDAI", "仙台新港", "JMA", 38.2667f, 141.0167f);
    jesim_add_observation_point("ONAHAMA", "小名浜", "JMA", 36.9333f, 140.9167f);
    jesim_add_observation_point("CHOSHI", "銚子漁港", "JMA", 35.7333f, 140.8667f);
    jesim_add_observation_point("TOKYO", "東京", "JMA", 35.6500f, 139.7667f);
    jesim_add_observation_point("YOKOHAMA", "横浜", "JMA", 35.4500f, 139.6500f);
    jesim_add_observation_point("IZU_OSHIMA_OKADA", "伊豆大島岡田", "JMA-VIRTUAL", 34.7820f, 139.3930f);
    jesim_add_observation_point("TOSHIMA", "利島", "JMA-VIRTUAL", 34.5200f, 139.2820f);
    jesim_add_observation_point("NIIJIMA", "新島", "JMA-VIRTUAL", 34.3770f, 139.2570f);
    jesim_add_observation_point("SHIKINEJIMA", "式根島", "JMA-VIRTUAL", 34.3260f, 139.2170f);
    jesim_add_observation_point("KOZUSHIMA", "神津島", "JMA-VIRTUAL", 34.2050f, 139.1340f);
    jesim_add_observation_point("MIYAKEJIMA_AKO", "三宅島阿古", "JMA-VIRTUAL", 34.0750f, 139.4800f);
    jesim_add_observation_point("MIKURAJIMA", "御蔵島", "JMA-VIRTUAL", 33.8750f, 139.6020f);
    jesim_add_observation_point("HACHIJOJIMA_YAENE", "八丈島八重根", "JMA-VIRTUAL", 33.1090f, 139.7750f);
    jesim_add_observation_point("AOGASHIMA", "青ヶ島", "JMA-VIRTUAL", 32.4580f, 139.7630f);
    /* v17: 清水/清水港 removed from JESIM observation exports because its station sample arrives too early. */
    jesim_add_observation_point("OMAEZAKI", "御前崎", "JMA", 34.6000f, 138.2167f);
    jesim_add_observation_point("NAGOYA", "名古屋", "JMA", 35.0833f, 136.8833f);
    jesim_add_observation_point("OSAKA", "大阪", "JMA", 34.6500f, 135.4333f);
    jesim_add_observation_point("KOBE", "神戸", "JMA", 34.6833f, 135.2000f);
    jesim_add_observation_point("HIROSHIMA", "広島", "JMA", 34.3500f, 132.4667f);
    jesim_add_observation_point("UWAJIMA", "宇和島", "JMA", 33.2167f, 132.5667f);
    jesim_add_observation_point("KOCHI", "高知", "JMA", 33.5000f, 133.5667f);
    jesim_add_observation_point("KAGOSHIMA", "鹿児島", "JMA", 31.5833f, 130.5667f);
    jesim_add_observation_point("NAZE", "名瀬", "JMA", 28.3833f, 129.5000f);
    jesim_add_observation_point("NAHA", "那覇", "JMA", 26.2167f, 127.6667f);
    jesim_add_observation_point("ISHIGAKI", "石垣", "JMA", 24.3333f, 124.1667f);
}

static int jesim_parse_observation_csv_line(char *line) {
    char *cols[8] = {0};
    int n = 0;
    char *p = line;
    while (p && n < 8) {
        cols[n++] = p;
        char *comma = strchr(p, ',');
        if (!comma) break;
        *comma = '\0';
        p = comma + 1;
    }
    if (n < 5) return 0;
    for (int i = 0; i < n; ++i) jesim_trim_ascii(cols[i]);
    if (cols[0][0] == '\0' || cols[0][0] == '#') return 0;

    if (_stricmp(cols[0], "id") == 0) {
        g_jesim_observation_csv_station_no_col = -1;
        g_jesim_observation_csv_zone_id_col = -1;
        for (int i = 0; i < n; ++i) {
            if (_stricmp(cols[i], "station_no") == 0 ||
                _stricmp(cols[i], "sim_station_no") == 0 ||
                _stricmp(cols[i], "mapped_station_no") == 0 ||
                _stricmp(cols[i], "station") == 0 ||
                _stricmp(cols[i], "station_id") == 0) {
                g_jesim_observation_csv_station_no_col = i;
            } else if (_stricmp(cols[i], "zone_id") == 0 ||
                       _stricmp(cols[i], "preferred_zone_id") == 0) {
                g_jesim_observation_csv_zone_id_col = i;
            }
        }
        return 0;
    }

    float lat = strtof(cols[3], NULL);
    float lon = strtof(cols[4], NULL);
    if (lat < 20.0f || lat > 50.0f || lon < 120.0f || lon > 150.0f) return 0;

    int station_no = 0;
    int zone_id = 0;
    if (g_jesim_observation_csv_station_no_col >= 0 &&
        g_jesim_observation_csv_station_no_col < n &&
        cols[g_jesim_observation_csv_station_no_col] &&
        cols[g_jesim_observation_csv_station_no_col][0]) {
        station_no = atoi(cols[g_jesim_observation_csv_station_no_col]);
    }
    if (g_jesim_observation_csv_zone_id_col >= 0 &&
        g_jesim_observation_csv_zone_id_col < n &&
        cols[g_jesim_observation_csv_zone_id_col] &&
        cols[g_jesim_observation_csv_zone_id_col][0]) {
        zone_id = atoi(cols[g_jesim_observation_csv_zone_id_col]);
    }

    /* Headerless compatibility:
       old 6-column files used col[5]=zone_id; new files should use a header. */
    if (g_jesim_observation_csv_station_no_col < 0 &&
        g_jesim_observation_csv_zone_id_col < 0 && n >= 6 && cols[5] && cols[5][0]) {
        int v = atoi(cols[5]);
        if (v > 93) station_no = v;
        else zone_id = v;
    }

    jesim_add_observation_point(cols[0], cols[1], cols[2], lat, lon);
    if (g_jesim_observation_point_count > 0) {
        JesimObservationPoint *op = &g_jesim_observation_points[g_jesim_observation_point_count - 1];
        if (station_no > 0) op->csv_station_no = station_no;
        if (zone_id > 0 && zone_id <= 93) op->preferred_zone_id = zone_id;
    }
    return 1;
}
static void jesim_load_observation_points(void) {
    if (g_jesim_observation_points_loaded) return;
    g_jesim_observation_points_loaded = 1;
    g_jesim_observation_point_count = 0;
    g_jesim_observation_csv_station_no_col = -1;
    g_jesim_observation_csv_zone_id_col = -1;
    FILE *fp = fopen(g_jesim_observation_points_path, "rb");
    if (fp) {
        char line[512];
        while (fgets(line, sizeof(line), fp)) {
            if (g_jesim_observation_point_count >= JESIM_MAX_OBSERVATION_POINTS) break;
            jesim_parse_observation_csv_line(line);
        }
        fclose(fp);
    }
    if (g_jesim_observation_point_count <= 0) jesim_load_builtin_observation_points();
    jesim_map_observation_points_to_stations();
}


static void jesim_free_wave_zone_map(void) {
    free(g_jesim_wave_zone_map);
    g_jesim_wave_zone_map = NULL;
    g_jesim_wave_zone_map_cells = 0;
}

static void jesim_rebuild_wave_zone_map(void) {
    if (!g_grid.station_index || !g_grid.station_id || g_grid.width == 0 || g_grid.height == 0) return;

    size_t cells = (size_t)g_grid.width * (size_t)g_grid.height;
    if (cells == 0) return;

    if (g_jesim_wave_zone_map_cells != cells) {
        jesim_free_wave_zone_map();
        g_jesim_wave_zone_map = (int *)calloc(cells, sizeof(int));
        if (!g_jesim_wave_zone_map) return;
        g_jesim_wave_zone_map_cells = cells;
    } else {
        memset(g_jesim_wave_zone_map, 0, cells * sizeof(int));
    }

    uint32_t *queue = (uint32_t *)malloc(cells * sizeof(uint32_t));
    if (!queue) return;

    uint32_t head = 0;
    uint32_t tail = 0;

    for (uint32_t i = 0; i < g_grid.station_count; ++i) {
        if (!station_is_enabled(&g_grid, i)) continue;
        int z = station_zone_id_for_display((int)i);
        if (z < 1 || z > 93 || z == 67) continue;

        int32_t idx = g_grid.station_index[i];
        if (idx < 0 || (size_t)idx >= cells) continue;

        if (g_jesim_wave_zone_map[idx] == 0) {
            g_jesim_wave_zone_map[idx] = z;
            queue[tail++] = (uint32_t)idx;
        }
    }

    /* Virtual/shallow-coast observation cells also seed their official forecast
       zone.  This prevents small island chains such as 伊豆諸島 from disappearing
       from the wavefield zone map when the original station line has no marker
       on the island itself. */
    jesim_load_observation_points();
    for (int oi = 0; oi < g_jesim_observation_point_count; ++oi) {
        JesimObservationPoint *op = &g_jesim_observation_points[oi];
        int z = op->preferred_zone_id;
        if (z < 1 || z > 93 || z == 67) continue;
        int32_t idx = op->mapped_grid_index;
        if (idx < 0 || (size_t)idx >= cells) continue;
        if (g_jesim_wave_zone_map[idx] == 0) {
            g_jesim_wave_zone_map[idx] = z;
            queue[tail++] = (uint32_t)idx;
        }
    }

    /*
       Assign every grid cell to the nearest forecast-zone station/virtual gauge
       by flood fill.  This gives a fast approximate zone mask for wavefield-based
       forecast upgrades.  Downgrade/release still uses the station/observation maxima.
    */
    const int dx4[4] = { 1, -1, 0, 0 };
    const int dy4[4] = { 0, 0, 1, -1 };

    while (head < tail) {
        uint32_t idx = queue[head++];
        int z = g_jesim_wave_zone_map[idx];
        int x = (int)(idx % g_grid.width);
        int y = (int)(idx / g_grid.width);

        for (int k = 0; k < 4; ++k) {
            int nx = x + dx4[k];
            int ny = y + dy4[k];
            if (nx < 0 || ny < 0 || nx >= (int)g_grid.width || ny >= (int)g_grid.height) continue;
            uint32_t nidx = (uint32_t)ny * g_grid.width + (uint32_t)nx;
            if (g_jesim_wave_zone_map[nidx] != 0) continue;
            g_jesim_wave_zone_map[nidx] = z;
            queue[tail++] = nidx;
        }
    }

    free(queue);
}

static int jesim_wave_zone_for_cell(size_t idx) {
    if (!g_jesim_wave_zone_map || idx >= g_jesim_wave_zone_map_cells) return 0;
    return g_jesim_wave_zone_map[idx];
}

static void jesim_export_metadata(const char *kind) {
    if (!g_jesim_export_enabled) return;
    jesim_load_observation_points();

    FILE *fp = fopen(jesim_outbox_path(), "ab");
    if (!fp) return;

    char now_buf[48];
    jesim_format_abs_time(g_time_s, now_buf, sizeof(now_buf));

    fputs("{\"schema\":1,\"type\":\"jesim.tsunami\"", fp);
    fputs(",\"kind\":", fp); jesim_json_escape(fp, kind ? kind : "metadata");
    fprintf(fp, ",\"sim_s\":%d", g_time_s);
    fputs(",\"origin_time\":\"1日 00:00\"", fp);
    fputs(",\"issue_time\":", fp); jesim_json_escape(fp, now_buf);
    fprintf(fp, ",\"station_count\":%u", (unsigned)g_grid.station_count);
    fprintf(fp, ",\"observation_point_count\":%d", g_jesim_observation_point_count);
    fputs(",\"observation_source\":\"jesim_observation_points.csv or built-in starter list\"", fp);
    fputs(",\"message\":\"metadata/test message from tsunami_gui.exe\"", fp);
    fputs(",\"forecast_height_steps\":[\"0.05m\",\"0.2m\",\"1m\",\"3m\",\"5m\",\"10m\",\"10m+\"]", fp);
    fputs(",\"forecast_height_rule\":\"ceil to official-like step; omit below 0.05m\"", fp);
    fputs(",\"arrival_round_min\":5", fp);
    fprintf(fp, ",\"advisory_update_only\":true");
    fprintf(fp, ",\"upgrade_only\":true");
    fprintf(fp, ",\"check_interval_h\":3");
    fprintf(fp, ",\"forecast_until_h\":36");
    fputs(",\"upgrade_basis\":\"wavefield forecast\",\"downgrade_basis\":\"observation points\"", fp);
    fputs(",\"supports_observation_arrival_events\":true", fp);
    fputs(",\"observation_arrival_kind\":\"observation_arrival\"", fp);
    fputs(",\"observation_arrival_rule\":\"fixed wall-clock 1x scheduled observation reports; first positive arrival and later height updates are both batched to 5-minute model-time buckets; after arrival every observation point is resent every 5 minutes; packet_meaningful_update is true when any observation in the packet updates its displayed peak; per-observation significance is indicated by rise only; clock starts at first model step; separate from 3-hour warning_check\"", fp);
    fputs(",\"observation_rise_fields\":[\"rise\",\"rise_state\",\"rise_m\",\"rise_label\",\"raw_rise_m\"]", fp);
    fputs(",\"observation_rise_rule\":\"rise is a per-observation peak-update marker only: + when that observation newly updates the displayed positive peak, otherwise 0. Records are still sent every 5 minutes for already-arrived observations. Packet-level packet_meaningful_update tells Scratch whether at least one observation in the whole packet updated its peak. Numeric rise_m/level_m/raw_rise_m remain for Scratch compatibility and are signed live sea-level change. peak_m is the maximum positive rise so far\"", fp);
    fputs(",\"realtime_sync_enabled\":true", fp);
    fprintf(fp, ",\"realtime_speed_factor\":%d", jesim_realtime_speed_factor());
    fputs(",\"realtime_speed_rule\":\"fixed_wall_clock_1x: 1 observation/model second per 1 external monotonic clock second\"", fp);
    fprintf(fp, ",\"calc_start_delay_s\":%d", g_jesim_calc_start_delay_s);
    fputs(",\"dispatch_rule\":\"observation_arrival uses fixed external wall-clock 1x; warning_check is dispatched by simulation time only\"", fp);
    fputs(",\"debug_note\":\"If this reaches Scratch, WebSocket relay path is working even before 10 simulated minutes.\"", fp);
    fputs("}\n", fp);
    fclose(fp);
}


static const char *jesim_zone_tts_name_from_id(int zone_id) {
    /*
       JSON/TTS name helper.
       Keep internal forecast_zone_name_from_id() unchanged for GUI/CSV.
       Korean KMA zones are converted to Japanese-readable TTS form:
         region -> Katakana pronunciation
         north/south/east/west/central -> 北部/南部/東部/西部/中部
         west/south/east sea -> 西海/南海/東海
         coast/sea-area -> 沿岸
    */
    switch (zone_id) {
    case 68: return "カンウォン 北部 沿岸";
    case 69: return "カンウォン 中部 沿岸";
    case 70: return "カンウォン 南部 沿岸";
    case 71: return "キョンブク 北部 沿岸";
    case 72: return "キョンブク 南部 沿岸";
    case 73: return "ウルサン 沿岸";
    case 74: return "プサン 沿岸";
    case 75: return "コジェ 東部 沿岸";
    case 76: return "キョンナム 中部 南海 沿岸";
    case 77: return "キョンナム 西部 南海 沿岸";
    case 78: return "チェジュド 北部 沿岸";
    case 79: return "チェジュド 東部 沿岸";
    case 80: return "チェジュド 南部 沿岸";
    case 81: return "チェジュド 西部 沿岸";
    case 82: return "チョンナム 東部 南海 沿岸";
    case 83: return "チョンナム 西部 南海 沿岸";
    case 84: return "チョンナム 南部 西海 沿岸";
    case 85: return "チョンナム 中部 西海 沿岸";
    case 86: return "チョンナム 北部 西海 沿岸";
    case 87: return "チョンブク 南部 西海 沿岸";
    case 88: return "チョンブク 北部 西海 沿岸";
    case 89: return "チュンナム 南部 西海 沿岸";
    case 90: return "チュンナム 北部 西海 沿岸";
    case 91: return "インチョン・キョンギ 南部 沿岸";
    case 92: return "インチョン・キョンギ 北部 沿岸";
    case 93: return "ウルルンド 沿岸";
    default:
        return forecast_zone_name_from_id(zone_id);
    }
}

static void jesim_export_snapshot(const char *kind, int completed_h, int issue_s) {
    if (!g_jesim_export_enabled || !g_grid.station_id || !g_sim.station_max_m) return;
    jesim_load_observation_points();
    jesim_ensure_obs_memory();
    int include_zone_forecast = (completed_h >= 6 && completed_h <= 36);

    enum { JESIM_MAX_ZONE = 999 };
    float zone_observed_max[JESIM_MAX_ZONE + 1];
    float zone_forecast_max[JESIM_MAX_ZONE + 1];
    int zone_observed_arrival[JESIM_MAX_ZONE + 1];
    int zone_forecast_arrival[JESIM_MAX_ZONE + 1];
    int zone_station[JESIM_MAX_ZONE + 1];

    for (int z = 0; z <= JESIM_MAX_ZONE; ++z) {
        zone_observed_max[z] = 0.0f;
        zone_forecast_max[z] = 0.0f;
        zone_observed_arrival[z] = 0;
        zone_forecast_arrival[z] = 0;
        zone_station[z] = 0;
    }

    /* Observation/station basis: used for downgrade/release. */
    for (uint32_t i = 0; i < g_grid.station_count; ++i) {
        if (!station_is_enabled(&g_grid, i)) continue;
        int z = station_zone_id_for_display((int)i);
        if (z < 1 || z > JESIM_MAX_ZONE || z == 67) continue;

        float peak = g_sim.station_max_m ? g_sim.station_max_m[i] : 0.0f;
        if (peak > zone_observed_max[z]) {
            zone_observed_max[z] = peak;
            zone_station[z] = (int)i + 1;
        }
        int arr = g_sim.station_arrival_s ? g_sim.station_arrival_s[i] : 0;
        if (arr > 0 && (zone_observed_arrival[z] == 0 || arr < zone_observed_arrival[z])) zone_observed_arrival[z] = arr;
    }

    /* Add observation-point based maxima too.  This covers virtual shallow-coast
       gauges (Izu/Ogasawara/small-island cells) that are not part of the legacy
       station_index array. */
    for (int oi = 0; oi < g_jesim_observation_point_count; ++oi) {
        int z = jesim_observation_zone_id_for_export(oi);
        if (z < 1 || z > JESIM_MAX_ZONE || z == 67) continue;
        float live = jesim_observation_live_signed_m(oi);
        float peak = jesim_observation_peak_positive_m(oi, live);
        if (peak > zone_observed_max[z]) {
            zone_observed_max[z] = peak;
            zone_station[z] = g_jesim_observation_points[oi].mapped_station_no;
        }
        int arr = g_jesim_observation_arrival_s[oi];
        if (arr > 0 && (zone_observed_arrival[z] == 0 || arr < zone_observed_arrival[z])) zone_observed_arrival[z] = arr;
    }

    /* Wavefield basis: used for forecast upgrade. */
    jesim_rebuild_wave_zone_map();
    if (g_jesim_wave_zone_map && g_sim.display_max_m) {
        size_t cells = (size_t)g_grid.width * (size_t)g_grid.height;
        for (size_t idx = 0; idx < cells; ++idx) {
            int z = jesim_wave_zone_for_cell(idx);
            if (z < 1 || z > JESIM_MAX_ZONE || z == 67) continue;
            if (display_cell_is_land(idx)) continue;
            if (is_display_excluded_cell(idx)) continue;

            size_t pidx = idx + g_grid.width;
            float h = g_sim.display_max_m[pidx];
            if (h > zone_forecast_max[z]) zone_forecast_max[z] = h;

            int arr = g_sim.display_arrival_s ? g_sim.display_arrival_s[pidx] : 0;
            if (arr > 0 && (zone_forecast_arrival[z] == 0 || arr < zone_forecast_arrival[z])) {
                zone_forecast_arrival[z] = arr;
            }
        }
    }

    int zone_update_changed[JESIM_MAX_ZONE + 1];
    int changed_zone_count = 0;
    memset(zone_update_changed, 0, sizeof(zone_update_changed));

    if (include_zone_forecast) {
        for (int z = 1; z <= 93; ++z) {
            if (z == 67) continue;

            float observed_step = jesim_forecast_height_step_m(zone_observed_max[z]);
            float forecast_step = jesim_forecast_height_step_m(zone_forecast_max[z]);
            int observed_rank = jesim_warning_height_rank(observed_step);
            int forecast_rank = jesim_warning_height_rank(forecast_step);

            int observed_arrival_5min_s = jesim_ceil_to_5min_s(zone_observed_arrival[z]);
            int forecast_arrival_5min_s = jesim_ceil_to_5min_s(zone_forecast_arrival[z]);

            int changed = jesim_zone_update_is_significant(z,
                                                           forecast_step,
                                                           observed_step,
                                                           forecast_rank,
                                                           observed_rank,
                                                           forecast_arrival_5min_s,
                                                           observed_arrival_5min_s);
            zone_update_changed[z] = changed;
            if (changed) changed_zone_count++;
        }

        if (changed_zone_count <= 0) {
            /* 3h check happened, but no forecast advisory-or-higher upgrade changed.
               Do not write any JSON line; relay/Scratch will not receive downgrade/noise updates. */
            return;
        }
    }

    FILE *fp = fopen(jesim_outbox_path(), "ab");
    if (!fp) return;

    if (issue_s < 0) issue_s = g_time_s;
    char now_buf[48];
    jesim_format_abs_time(issue_s, now_buf, sizeof(now_buf));

    int effective_time_s = completed_h > 0 ? completed_h * 3600 : issue_s;
    char effective_buf[48];
    jesim_format_abs_time(effective_time_s, effective_buf, sizeof(effective_buf));

    fputs("{\"schema\":1,\"type\":\"jesim.tsunami\"", fp);
    fputs(",\"kind\":", fp); jesim_json_escape(fp, kind ? kind : "snapshot");
    fprintf(fp, ",\"sim_s\":%d,\"completed_h\":%d", issue_s, completed_h);
    fprintf(fp, ",\"computed_model_s\":%d", g_time_s);
    fprintf(fp, ",\"real_sync_time_s\":%d", jesim_realtime_sync_s());
    fprintf(fp, ",\"calc_start_delay_s\":%d", g_jesim_calc_start_delay_s);
    fprintf(fp, ",\"effective_time_s\":%d", effective_time_s);
    fputs(",\"effective_time\":", fp); jesim_json_escape(fp, effective_buf);
    fputs(",\"timer_rule\":\"3-hour warning_check through 36h; upgrade-only mode; transmit only forecast advisory-or-higher upgrade changes\"", fp);
    fputs(",\"bulletin_class\":\"forecast_warning_check\"", fp);
    fputs(",\"event_driven\":false", fp);
    fputs(",\"is_3hour_check\":true", fp);
    fputs(",\"origin_time\":\"1日 00:00\"", fp);
    fputs(",\"issue_time\":", fp); jesim_json_escape(fp, now_buf);
    fputs(",\"forecast_height_rule\":\"ceil 0.05/0.2/1/3/5/10/10+; omit below 0.05m\"", fp);
    fputs(",\"arrival_round_min\":5", fp);
    fprintf(fp, ",\"changed_zone_count\":%d", changed_zone_count);
    fprintf(fp, ",\"advisory_update_only\":true");
    fprintf(fp, ",\"upgrade_only\":true");
    fprintf(fp, ",\"check_interval_h\":3");
    fprintf(fp, ",\"forecast_until_h\":36");
    fputs(",\"zone_forecast_included\":", fp);
    fputs(include_zone_forecast ? "true" : "false", fp);

    int first = 1;
    if (include_zone_forecast) {
        fputs(",\"zones\":[", fp);
        first = 1;
        for (int z = 1; z <= 93; ++z) {
            if (z == 67) continue;

            float observed_step = jesim_forecast_height_step_m(zone_observed_max[z]);
            float forecast_step = jesim_forecast_height_step_m(zone_forecast_max[z]);

            if (!first) fputc(',', fp);
            first = 0;

            int first_arrival_s = zone_observed_arrival[z];
            int arr_round = jesim_ceil_to_5min_s(first_arrival_s);
            char arr_buf[48] = "0";
            if (arr_round > 0) jesim_format_abs_time(arr_round, arr_buf, sizeof(arr_buf));

            int forecast_first_arrival_s = zone_forecast_arrival[z];
            int forecast_arr_round = jesim_ceil_to_5min_s(forecast_first_arrival_s);
            char forecast_arr_buf[48] = "0";
            if (forecast_arr_round > 0) jesim_format_abs_time(forecast_arr_round, forecast_arr_buf, sizeof(forecast_arr_buf));

            fprintf(fp, "{\"zone\":%d,\"name\":", z);
            jesim_json_escape(fp, jesim_zone_tts_name_from_id(z));

            /* Existing keys are kept as observation/station basis for downgrade compatibility. */
            fputs(",\"height_step\":", fp); jesim_write_forecast_height_label(fp, observed_step);
            fprintf(fp, ",\"height_rank\":%d", jesim_warning_height_rank(observed_step));

            int forecast_rank = jesim_warning_height_rank(forecast_step);
            int observed_rank = jesim_warning_height_rank(observed_step);

            /* New forecast/wavefield keys are used for upgrade. */
            fputs(",\"forecast_height_step\":", fp); jesim_write_forecast_height_label(fp, forecast_step);
            fprintf(fp, ",\"forecast_height_rank\":%d", forecast_rank);
            fprintf(fp, ",\"forecast_raw_max_m\":%.3f", zone_forecast_max[z]);
            fprintf(fp, ",\"forecast_first_arrival_s\":%d,\"forecast_first_arrival_5min_s\":%d", forecast_first_arrival_s, forecast_arr_round);
            fputs(",\"forecast_first_arrival_time\":", fp); jesim_json_escape(fp, forecast_arr_buf);
            fputs(",\"forecast_basis\":\"wavefield_zone_map\"", fp);
            fputs(",\"decision_mode\":\"upgrade_only\"", fp);
            fprintf(fp, ",\"update_changed\":%s", zone_update_changed[z] ? "true" : "false");

            fputs(",\"upgrade_decision\":", fp); jesim_json_escape(fp, jesim_upgrade_decision_from_step(forecast_step));
            fprintf(fp, ",\"upgrade_effective_s\":%d", 0);

            fputs(",\"downgrade_decision\":", fp); jesim_json_escape(fp, jesim_downgrade_decision_from_step(observed_step));
            fprintf(fp, ",\"downgrade_effective_s\":%d", effective_time_s);

            /* Backward-compatible aliases.
               Existing Scratch/JESIM JSON code may already read these keys.
               Keep them stable: warning_decision means the delayed observation-based downgrade/release decision. */
            fputs(",\"warning_decision\":", fp); jesim_json_escape(fp, jesim_downgrade_decision_from_step(observed_step));
            fprintf(fp, ",\"decision_effective_s\":%d", effective_time_s);

            fprintf(fp, ",\"raw_max_m\":%.3f", zone_observed_max[z]);
            fprintf(fp, ",\"observed_raw_max_m\":%.3f", zone_observed_max[z]);
            fputs(",\"observed_height_step\":", fp); jesim_write_forecast_height_label(fp, observed_step);
            fprintf(fp, ",\"observed_height_rank\":%d", observed_rank);
            fprintf(fp, ",\"first_arrival_s\":%d,\"first_arrival_5min_s\":%d", first_arrival_s, arr_round);
            fputs(",\"first_arrival_time\":", fp); jesim_json_escape(fp, arr_buf);
            fputs(",\"downgrade_basis\":\"observation_points\"", fp);
            fputc('}', fp);
        }
        fputs("]", fp);
        fputs("}\n", fp);

        for (int z = 1; z <= 93; ++z) {
            if (z == 67) continue;

            float observed_step = jesim_forecast_height_step_m(zone_observed_max[z]);
            float forecast_step = jesim_forecast_height_step_m(zone_forecast_max[z]);

            g_jesim_last_sent_forecast_rank[z] = jesim_warning_height_rank(forecast_step);
            g_jesim_last_sent_observed_rank[z] = jesim_warning_height_rank(observed_step);
            g_jesim_last_sent_forecast_active[z] = jesim_is_advisory_or_higher_step(forecast_step);
            g_jesim_last_sent_observed_active[z] = jesim_is_advisory_or_higher_step(observed_step);
            g_jesim_last_sent_forecast_arrival_5min_s[z] = jesim_ceil_to_5min_s(zone_forecast_arrival[z]);
            g_jesim_last_sent_observed_arrival_5min_s[z] = jesim_ceil_to_5min_s(zone_observed_arrival[z]);
        }

        fclose(fp);
        return;
    }

    fputs(",\"observations\":[", fp);
    first = 1;
    int dt_s = (g_jesim_prev_obs_time_s >= 0) ? (g_time_s - g_jesim_prev_obs_time_s) : 0;
    for (int oi = 0; oi < g_jesim_observation_point_count; ++oi) {
        JesimObservationPoint *op = &g_jesim_observation_points[oi];
        int station_no = op->mapped_station_no;
        float live = jesim_observation_live_signed_m(oi);
        float peak = jesim_observation_peak_positive_m(oi, live);
        float live_display = jesim_observation_round_signed_0p1m(live);
        float peak_display = jesim_observation_round_0p1m(peak);
        if (live_display == 0.0f && peak_display <= 0.0f) continue;
        float prev = (oi >= 0 && oi < JESIM_MAX_OBSERVATION_POINTS) ? g_jesim_prev_obs_point_m[oi] : 0.0f;
        float dh = (dt_s > 0) ? (live - prev) : 0.0f;
        int rise_sign = 0;
        const char *trend = "unknown";
        if (dt_s > 0) {
            if (dh >= 0.05f) {
                trend = "rising";
                rise_sign = 1;
            } else if (dh <= -0.05f) {
                trend = "falling";
                rise_sign = -1;
            } else {
                trend = "steady";
                rise_sign = 0;
            }
        }
        if (!first) fputc(',', fp);
        first = 0;
        fprintf(fp, "{\"obs_index\":%d,\"obs_index0\":%d,\"obs_no\":%d", oi + 1, oi, oi + 1);
        fputs(",\"obs_id\":", fp); jesim_json_escape(fp, op->id);
        fputs(",\"name\":", fp); jesim_json_escape(fp, op->name);
        fputs(",\"agency\":", fp); jesim_json_escape(fp, op->agency);
        fprintf(fp, ",\"lat\":%.4f,\"lon\":%.4f", op->lat_deg, op->lon_deg);
        fprintf(fp, ",\"gauge_lat\":%.4f,\"gauge_lon\":%.4f", op->lat_deg, op->lon_deg);
        int op_disp_x = -1, op_disp_y = -1;
        if (jesim_observation_display_xy_from_latlon(op, &op_disp_x, &op_disp_y)) {
            fputs(",\"coordinate_source\":\"csv_latlon_display_station_sampling\"", fp);
        } else {
            fputs(",\"coordinate_source\":\"existing_station_id_grid_coordinate\"", fp);
        }
        fprintf(fp, ",\"sim_station_no\":%d", station_no);
        fprintf(fp, ",\"csv_station_no\":%d", station_no);
        fprintf(fp, ",\"csv_station_no_duplicate\":false");
        fprintf(fp, ",\"mapped_grid_x\":%d,\"mapped_grid_y\":%d", op->mapped_grid_x, op->mapped_grid_y);
        fprintf(fp, ",\"mapped_depth_m\":%.1f", op->mapped_depth_m);
        int sample_idx = -1;
        if (station_no > 0 && (uint32_t)(station_no - 1) < g_grid.station_count) {
            int si_dbg = station_no - 1;
            sample_idx = g_grid.station_sample_index ? g_grid.station_sample_index[si_dbg] : g_grid.station_index[si_dbg];
        } else {
            sample_idx = op->mapped_grid_index;
        }
        int sample_x = -1;
        int sample_y = -1;
        size_t sample_cells = (size_t)g_grid.width * (size_t)g_grid.height;
        if (sample_idx >= 0 && (size_t)sample_idx < sample_cells) {
            sample_x = sample_idx % (int)g_grid.width;
            sample_y = sample_idx / (int)g_grid.width;
        }
        fprintf(fp, ",\"sample_grid_x\":%d,\"sample_grid_y\":%d", sample_x, sample_y);
        fputs(",\"sample_source\":\"existing_station_sample_index\"", fp);
        if (op_disp_x >= 0 && op_disp_y >= 0) {
            fputs(",\"mapped_coordinate_role\":\"display_from_csv_latlon_sampling_from_existing_station_or_local_cell\"", fp);
        } else {
            fputs(",\"mapped_coordinate_role\":\"existing_station_index_from_sim_station_no\"", fp);
        }
        fputs(",\"sample_coordinate_role\":\"existing_station_sample_index_debug\"", fp);
        fprintf(fp, ",\"virtual_coast_cell\":%s", op->mapped_is_virtual_coast ? "true" : "false");
        fputs(",\"update_time\":", fp); jesim_json_escape(fp, now_buf);
        fprintf(fp, ",\"height_m\":%.1f,\"height_label\":\"%.1fm\"", live_display, live_display);
        fputs(",\"rise\":", fp); jesim_json_escape(fp, g_jesim_observation_rise_symbol(rise_sign));
        fputs(",\"rise_state\":", fp); jesim_json_escape(fp, g_jesim_observation_rise_text(rise_sign));
        fprintf(fp, ",\"rise_m\":%.1f,\"rise_label\":\"%.1fm\"", live_display, live_display);
        fprintf(fp, ",\"level_m\":%.1f,\"level_label\":\"%.1fm\"", live_display, live_display);
        fprintf(fp, ",\"drawdown_m\":%.1f,\"drawdown_label\":\"%.1fm\"", live_display < 0.0f ? -live_display : 0.0f, live_display < 0.0f ? -live_display : 0.0f);
        fprintf(fp, ",\"peak_m\":%.1f,\"peak_label\":\"%.1fm\"", peak_display, peak_display);
        fprintf(fp, ",\"raw_live_m\":%.3f,\"raw_rise_m\":%.3f,\"raw_level_m\":%.3f", live, live, live);
        fputs(",\"trend\":", fp); jesim_json_escape(fp, trend);
        float rate_cm_min = (dt_s > 0) ? (dh * 100.0f / ((float)dt_s / 60.0f)) : 0.0f;
        fprintf(fp, ",\"trend_cm_per_min\":%.3f", rate_cm_min);
        fputc('}', fp);
    }
    fputs("]}\n", fp);
    fclose(fp);

    for (int oi = 0; oi < g_jesim_observation_point_count && oi < JESIM_MAX_OBSERVATION_POINTS; ++oi) {
        g_jesim_prev_obs_point_m[oi] = jesim_observation_live_signed_m(oi);
    }
    g_jesim_prev_obs_time_s = g_time_s;
}


static const char *jesim_observation_report_reason_text(int reason) {
    if (reason == JESIM_OBS_REPORT_HEIGHT_UPDATE) return "height_update_at_observation_point";
    return "first_arrival_at_observation_point";
}

static int jesim_queue_observation_report(int oi,
                                          int model_s,
                                          int observed_s,
                                          float live,
                                          float peak,
                                          int reason) {
    if (oi < 0 || oi >= JESIM_MAX_OBSERVATION_POINTS) return 0;
    if (oi >= g_jesim_observation_point_count) return 0;
    if (model_s <= 0) return 0;
    if (observed_s <= 0) observed_s = model_s;
    if (g_jesim_observation_event_count >= JESIM_OBSERVATION_EVENT_QUEUE_CAP) return 0;

    /* Defensive fallback: if a report is produced while the run clock is still
       inactive, start it here too.  In normal Run/Step operation the clock has
       already started in advance_one_step(). */
    if (!g_jesim_realtime_active || g_jesim_realtime_start_ms == 0) {
        jesim_start_realtime_warning_clock();
    }

    int live_dm = jesim_observation_display_dm_signed(live);
    int peak_dm = jesim_observation_display_dm_positive(peak);
    int prev_peak_dm = g_jesim_observation_last_queued_peak_dm[oi];
    int peak_delta_dm = peak_dm - prev_peak_dm;
    int peak_updated = (peak_delta_dm > 0 && peak_dm > 0);
    int rise_sign = peak_updated ? 1 : 0;

    /* v30: once a point has arrived, keep sending its current observation state
       on every 5-minute bulletin.  Do not suppress records just because the
       displayed positive peak did not change; instead expose that as JSON. */
    if (live_dm == 0 && peak_dm <= 0) return 0;

    /* Avoid exact duplicate queued events for the same point/time/reason. */
    for (int i = g_jesim_observation_event_count - 1; i >= 0; --i) {
        JesimObservationReportEvent *ev = &g_jesim_observation_event_queue[i];
        if (ev->observation_index != oi) continue;
        if (ev->model_s != model_s) break;
        if (ev->reason == reason && ev->live_dm == live_dm && ev->peak_dm == peak_dm) return 0;
    }

    JesimObservationReportEvent *ev = &g_jesim_observation_event_queue[g_jesim_observation_event_count++];
    memset(ev, 0, sizeof(*ev));
    ev->observation_index = oi;
    ev->model_s = model_s;
    ev->observed_s = observed_s;
    ev->live_m = live;
    ev->peak_m = peak;
    ev->live_dm = live_dm;
    ev->peak_dm = peak_dm;
    ev->rise_sign = rise_sign;
    ev->meaningful_peak_update = peak_updated ? 1 : 0;
    ev->peak_delta_dm = peak_updated ? peak_delta_dm : 0;
    ev->reason = reason;

    {
        JesimObservationPoint *op = &g_jesim_observation_points[oi];
        int station_no = op->mapped_station_no;
        int sample_idx = -1;
        if (station_no > 0 && (uint32_t)(station_no - 1) < g_grid.station_count) {
            int si_dbg = station_no - 1;
            int32_t station_sample = g_grid.station_sample_index ? g_grid.station_sample_index[si_dbg] : g_grid.station_index[si_dbg];
            if (station_sample >= 0) sample_idx = station_sample;
        } else {
            sample_idx = op->mapped_grid_index;
        }
        int sample_x = -1;
        int sample_y = -1;
        size_t sample_cells = (size_t)g_grid.width * (size_t)g_grid.height;
        if (sample_idx >= 0 && (size_t)sample_idx < sample_cells) {
            sample_x = sample_idx % (int)g_grid.width;
            sample_y = sample_idx / (int)g_grid.width;
        }

        snprintf(ev->obs_id, sizeof(ev->obs_id), "%s", op->id);
        snprintf(ev->name, sizeof(ev->name), "%s", op->name);
        snprintf(ev->agency, sizeof(ev->agency), "%s", op->agency);
        ev->lat_deg = op->lat_deg;
        ev->lon_deg = op->lon_deg;
        ev->station_no = station_no;
        ev->mapped_grid_index = op->mapped_grid_index;
        ev->mapped_grid_x = op->mapped_grid_x;
        ev->mapped_grid_y = op->mapped_grid_y;
        ev->sample_grid_index = sample_idx;
        ev->sample_grid_x = sample_x;
        ev->sample_grid_y = sample_y;
        ev->mapped_is_virtual_coast = op->mapped_is_virtual_coast;
        ev->mapped_depth_m = op->mapped_depth_m;
        ev->preferred_zone_id = op->preferred_zone_id;
        ev->forecast_zone_id = jesim_observation_zone_id_for_export(oi);
    }

    ev->sent = 0;

    g_jesim_observation_last_queued_live_dm[oi] = live_dm;
    if (peak_dm > g_jesim_observation_last_queued_peak_dm[oi]) {
        g_jesim_observation_last_queued_peak_dm[oi] = peak_dm;
    }
    return 1;
}

static void jesim_cache_computed_observation_arrivals(void) {
    jesim_load_observation_points();
    if (g_jesim_observation_point_count <= 0) return;

    for (int oi = 0; oi < g_jesim_observation_point_count; ++oi) {
        JesimObservationPoint *op = &g_jesim_observation_points[oi];
        int station_no = op->mapped_station_no;
        int si = station_no - 1;

        float live = jesim_observation_live_signed_m(oi);
        float peak = jesim_observation_peak_positive_m(oi, live);

        int arr_s = 0;
        if (g_jesim_observation_arrival_s[oi] > 0) {
            arr_s = g_jesim_observation_arrival_s[oi];
        } else if (op->mapped_grid_index < 0 &&
                   si >= 0 && (uint32_t)si < g_grid.station_count && g_sim.station_arrival_s) {
            /* Fallback only.  Normal JESIM observation arrivals are now derived
               from the observation's own local live/peak value below, not from
               the nearest legacy station slot. */
            arr_s = g_sim.station_arrival_s[si];
        }

        /* JESIM observation arrival is news-style first positive rise.
           Do not queue a first-arrival report from the initial drawdown/
           subsidence side of the source deformation footprint. */
        if (arr_s <= 0 && (live >= 0.05f || peak >= 0.05f)) {
            arr_s = g_time_s;
            g_jesim_observation_arrival_s[oi] = arr_s;
        }

        if (arr_s <= 0 || arr_s > g_time_s) continue;

        int queued_first_now = 0;

        /* First-arrival reports are also batched to the next 5-minute
           model-time bucket.  This keeps JESIM observation bulletins closer to
           real news/tide-gauge publishing cadence instead of sending an
           immediate message for every single gauge as soon as the threshold is
           crossed.  The stored g_jesim_observation_arrival_s remains the true
           threshold-crossing model time; the queued report time is the public
           bulletin bucket. */
        int first_report_s = jesim_observation_report_bucket_s(arr_s);
        if (!g_jesim_observation_first_queued[oi] &&
            first_report_s > 0 && g_time_s >= first_report_s) {
            if (jesim_queue_observation_report(oi, first_report_s, arr_s, live, peak,
                                               JESIM_OBS_REPORT_FIRST_ARRIVAL)) {
                g_jesim_observation_first_queued[oi] = 1;
                queued_first_now = 1;
            }
        }

        /* After the first-arrival bulletin, publish every arrived observation
           on each 5-minute model-time bucket.  The record is still marked with
           meaningful_update/meaningful_peak_update/peak_updated when the
           displayed positive peak actually increases, but unchanged live/peak
           states are no longer suppressed. */
        int update_interval_s = JESIM_OBSERVATION_HEIGHT_UPDATE_INTERVAL_S;
        if (update_interval_s < 1) update_interval_s = 300;
        int on_update_bucket = (g_time_s > 0 && (g_time_s % update_interval_s) == 0);
        if (g_jesim_observation_first_queued[oi] && !queued_first_now && on_update_bucket) {
            jesim_queue_observation_report(oi, g_time_s, g_time_s, live, peak,
                                           JESIM_OBS_REPORT_HEIGHT_UPDATE);
        }
    }
}

static void jesim_export_observation_arrivals(void) {
    if (!g_jesim_export_enabled) return;
    if (!g_jesim_realtime_active || g_jesim_realtime_start_ms == 0) return;

    jesim_cache_computed_observation_arrivals();
    if (g_jesim_observation_point_count <= 0) return;
    if (g_jesim_observation_event_count <= 0) return;

    int sync_s = jesim_realtime_sync_s();
    int pending = 0;
    int earliest_event_s = 0;
    int contains_height_updates = 0;
    int contains_meaningful_peak_updates = 0;
    for (int qi = 0; qi < g_jesim_observation_event_count; ++qi) {
        JesimObservationReportEvent *ev = &g_jesim_observation_event_queue[qi];
        if (ev->sent) continue;
        if (!jesim_realtime_event_due_s(ev->model_s)) continue;
        if (ev->live_dm == 0 && ev->peak_dm <= 0) {
            ev->sent = 1;
            continue;
        }
        pending++;
        if (ev->reason == JESIM_OBS_REPORT_HEIGHT_UPDATE) contains_height_updates = 1;
        if (ev->meaningful_peak_update) contains_meaningful_peak_updates = 1;
        if (earliest_event_s == 0 || ev->model_s < earliest_event_s) earliest_event_s = ev->model_s;
    }
    if (pending <= 0) return;

    FILE *fp = fopen(jesim_outbox_path(), "ab");
    if (!fp) return;

    char issue_buf[48];
    char effective_buf[48];
    jesim_format_abs_time(sync_s, issue_buf, sizeof(issue_buf));
    jesim_format_abs_time(earliest_event_s > 0 ? earliest_event_s : sync_s,
                          effective_buf, sizeof(effective_buf));

    fputs("{\"schema\":1,\"type\":\"jesim.tsunami\"", fp);
    fputs(",\"kind\":\"observation_arrival\"", fp);
    fputs(",\"bulletin_class\":\"observation_point_event\"", fp);
    fputs(",\"event_driven\":false", fp);
    fputs(",\"is_3hour_check\":false", fp);
    fprintf(fp, ",\"sim_s\":%d", sync_s);
    fprintf(fp, ",\"computed_model_s\":%d", g_time_s);
    fprintf(fp, ",\"real_elapsed_s\":%d", jesim_realtime_elapsed_s());
    fprintf(fp, ",\"real_sync_time_s\":%d", sync_s);
    fprintf(fp, ",\"realtime_speed_factor\":%d", jesim_realtime_speed_factor());
    fputs(",\"realtime_speed_rule\":\"fixed_wall_clock_1x: 1 observation/model second per 1 external monotonic clock second\"", fp);
    fprintf(fp, ",\"calc_start_delay_s\":%d", g_jesim_calc_start_delay_s);
    fprintf(fp, ",\"effective_time_s\":%d", earliest_event_s > 0 ? earliest_event_s : sync_s);
    fputs(",\"effective_time\":", fp); jesim_json_escape(fp, effective_buf);
    fputs(",\"issue_time\":", fp); jesim_json_escape(fp, issue_buf);
    fputs(",\"origin_time\":\"1日 00:00\"", fp);
    fputs(",\"timer_rule\":\"fixed external wall-clock 1x scheduled observation-point reports; first arrivals and later observation value updates are both batched to 5-minute model-time buckets; independent from 3-hour warning_check\"", fp);
    fputs(",\"height_rule\":\"first observation threshold uses positive rise >= +0.05m at the mapped sample cell; mapped/display x/y are rebuilt from CSV lat/lon when available, while sample_grid_x/y remains station/sample debug only; initial drawdown/subsidence does not lock arrival; first-arrival reports are delayed to the next 5-minute observation bulletin bucket but observed_time_s remains the true threshold-crossing time; after arrival every observation point is emitted every 5 minutes; rise marks per-observation peak update and packet_meaningful_update marks whether any observation in the whole packet updated its displayed positive peak by 0.1m; height_m reports the larger of live level and recorded positive peak, while level_m remains live\"", fp);
    fputs(",\"packet_meaningful_update_rule\":\"packet_meaningful_update is true if at least one observation record in this 5-minute packet newly updates the displayed positive peak by at least one 0.1m step; it is false only when no observation in the packet updates its peak. Per-observation judgement is rise: + means updated, 0 means regular resend\"", fp);
    fprintf(fp, ",\"observation_count\":%d", pending);
    fprintf(fp, ",\"contains_height_updates\":%s", contains_height_updates ? "true" : "false");
    fprintf(fp, ",\"packet_meaningful_update\":%s", contains_meaningful_peak_updates ? "true" : "false");
    fputs(",\"observations\":[", fp);

    int first = 1;
    for (int qi = 0; qi < g_jesim_observation_event_count; ++qi) {
        JesimObservationReportEvent *ev = &g_jesim_observation_event_queue[qi];
        if (ev->sent) continue;
        if (!jesim_realtime_event_due_s(ev->model_s)) continue;
        if (ev->live_dm == 0 && ev->peak_dm <= 0) {
            ev->sent = 1;
            continue;
        }

        int oi = ev->observation_index;
        if (oi < 0 || oi >= g_jesim_observation_point_count) continue;
        int station_no = ev->station_no;

        float live = ev->live_m;
        float peak = ev->peak_m;
        float live_display = ((float)ev->live_dm) / 10.0f;
        float peak_display = ((float)ev->peak_dm) / 10.0f;
        int report_dm = ev->live_dm;
        const char *report_height_source = "live_level";
        if (ev->peak_dm > abs(ev->live_dm)) {
            report_dm = ev->peak_dm;
            report_height_source = "positive_peak";
        }
        float report_display = ((float)report_dm) / 10.0f;
        int zone_id = ev->forecast_zone_id > 0 ? ev->forecast_zone_id : jesim_observation_zone_id_for_export(oi);
        int observed_s = ev->observed_s > 0 ? ev->observed_s : ev->model_s;
        char arr_buf[48];
        jesim_format_abs_time(observed_s, arr_buf, sizeof(arr_buf));

        if (!first) fputc(',', fp);
        first = 0;
        fprintf(fp, "{\"obs_index\":%d,\"obs_index0\":%d,\"obs_no\":%d", oi + 1, oi, oi + 1);
        fprintf(fp, ",\"queue_obs_index\":%d,\"queue_obs_index0\":%d", oi + 1, oi);
        fputs(",\"obs_id\":", fp); jesim_json_escape(fp, ev->obs_id);
        fputs(",\"queue_obs_id\":", fp); jesim_json_escape(fp, ev->obs_id);
        fputs(",\"name\":", fp); jesim_json_escape(fp, ev->name);
        fputs(",\"agency\":", fp); jesim_json_escape(fp, ev->agency);
        fprintf(fp, ",\"lat\":%.4f,\"lon\":%.4f", ev->lat_deg, ev->lon_deg);
        fprintf(fp, ",\"gauge_lat\":%.4f,\"gauge_lon\":%.4f", ev->lat_deg, ev->lon_deg);
        int ev_disp_x = -1, ev_disp_y = -1;
        if (jesim_display_xy_from_latlon_values(ev->lat_deg, ev->lon_deg, &ev_disp_x, &ev_disp_y)) {
            fputs(",\"coordinate_source\":\"csv_latlon_display_station_sampling\"", fp);
        } else {
            fputs(",\"coordinate_source\":\"existing_station_id_grid_coordinate\"", fp);
        }
        fprintf(fp, ",\"sim_station_no\":%d", station_no);
        fprintf(fp, ",\"csv_station_no\":%d", station_no);
        fprintf(fp, ",\"csv_station_no_duplicate\":false");
        fprintf(fp, ",\"mapped_grid_x\":%d,\"mapped_grid_y\":%d", ev->mapped_grid_x, ev->mapped_grid_y);
        fprintf(fp, ",\"grid_x\":%d,\"grid_y\":%d", ev->mapped_grid_x, ev->mapped_grid_y);
        fprintf(fp, ",\"display_grid_x\":%d,\"display_grid_y\":%d", ev->mapped_grid_x, ev->mapped_grid_y);
        fprintf(fp, ",\"mapped_depth_m\":%.1f", ev->mapped_depth_m);
        fprintf(fp, ",\"sample_grid_x\":%d,\"sample_grid_y\":%d", ev->sample_grid_x, ev->sample_grid_y);
        fputs(",\"sample_source\":\"existing_station_sample_index\"", fp);
        if (ev_disp_x >= 0 && ev_disp_y >= 0) {
            fputs(",\"mapped_coordinate_role\":\"display_from_csv_latlon_sampling_from_existing_station_or_local_cell\"", fp);
        } else {
            fputs(",\"mapped_coordinate_role\":\"existing_station_index_from_sim_station_no\"", fp);
        }
        fputs(",\"sample_coordinate_role\":\"existing_station_sample_index_debug\"", fp);
        fputs(",\"legacy_station_no_role\":\"primary_existing_station_mapping\"", fp);
        fputs(",\"event_record_bound_to_observation\":true", fp);
        fprintf(fp, ",\"virtual_coast_cell\":%s", ev->mapped_is_virtual_coast ? "true" : "false");
        fprintf(fp, ",\"preferred_zone_id\":%d", ev->preferred_zone_id);
        fprintf(fp, ",\"forecast_zone_id\":%d", zone_id);
        fputs(",\"forecast_zone_name\":", fp); jesim_json_escape(fp, jesim_zone_tts_name_from_id(zone_id));
        fprintf(fp, ",\"observed_time_s\":%d", observed_s);
        fputs(",\"observed_time\":", fp); jesim_json_escape(fp, arr_buf);
        fprintf(fp, ",\"report_bucket_s\":%d", ev->model_s);
        fprintf(fp, ",\"scheduled_bulletin_s\":%d", ev->model_s);
        fputs(",\"update_time\":", fp); jesim_json_escape(fp, issue_buf);
        fprintf(fp, ",\"height_m\":%.1f,\"height_label\":\"%.1fm\"", report_display, report_display);
        fprintf(fp, ",\"reported_height_m\":%.1f", report_display);
        fputs(",\"reported_height_source\":", fp); jesim_json_escape(fp, report_height_source);
        fputs(",\"rise\":", fp); jesim_json_escape(fp, g_jesim_observation_rise_symbol(ev->rise_sign));
        fputs(",\"rise_state\":", fp); jesim_json_escape(fp, g_jesim_observation_rise_text(ev->rise_sign));
        fprintf(fp, ",\"rise_m\":%.1f,\"rise_label\":\"%.1fm\"", live_display, live_display);
        fprintf(fp, ",\"level_m\":%.1f,\"level_label\":\"%.1fm\"", live_display, live_display);
        fprintf(fp, ",\"drawdown_m\":%.1f,\"drawdown_label\":\"%.1fm\"", live_display < 0.0f ? -live_display : 0.0f, live_display < 0.0f ? -live_display : 0.0f);
        fprintf(fp, ",\"peak_m\":%.1f,\"peak_label\":\"%.1fm\"", peak_display, peak_display);
        fprintf(fp, ",\"raw_live_m\":%.3f,\"raw_rise_m\":%.3f,\"raw_level_m\":%.3f,\"raw_peak_m\":%.3f", live, live, live, peak);
        fputs(",\"report_reason\":", fp); jesim_json_escape(fp, jesim_observation_report_reason_text(ev->reason));
        fputs(",\"arrival_threshold_m\":0.05", fp);
        fputs(",\"arrival_condition\":\"positive_rise_only_no_initial_drawdown\"", fp);
        fputc('}', fp);

        ev->sent = 1;
    }

    fputs("]}\n", fp);
    fclose(fp);

    set_status("Sent JESIM observation_arrival: %d observation(s), sync %ds, model %.2fh.",
               pending, sync_s, g_time_s / 3600.0f);
}

static void jesim_export_tick(void) {
    if (!g_jesim_export_enabled) return;

    /* Cache newly computed observation arrivals even when their real dispatch
       time has not arrived yet. */
    jesim_cache_computed_observation_arrivals();
    jesim_export_observation_arrivals();

    while (g_jesim_next_warning_check_h <= 36) {
        int check_h = g_jesim_next_warning_check_h;
        int check_s = check_h * 3600;

        /* warning_check is forecast/warning data, so it is driven only by
           simulation/model time.  Do not gate it with the real-time sync clock;
           that clock is only for observation_arrival news-style reports. */
        if (g_time_s < check_s) break;

        if (check_h == 6) g_jesim_sent_6h = 1;
        if (check_h == 12) g_jesim_sent_12h = 1;
        if (check_h == 24) g_jesim_sent_24h = 1;

        jesim_export_snapshot("warning_check", check_h, check_s);

        /* First check is 6h, then every 3h through 36h:
           6, 9, 12, 15, 18, 21, 24, 27, 30, 33, 36. */
        g_jesim_next_warning_check_h += 3;
    }
}



static void jesim_realtime_update_tick(void) {
    /* Release due JESIM records.  observation_arrival uses the synchronized real
       clock; warning_check uses simulation/model time only. */
    jesim_export_tick();
}

static int custom_zone_assignment_count(void) {
    init_station_zone_assignments();
    int n = 0;
    int max_i = (int)g_grid.station_count;
    if (max_i > MAX_STATION_ZONE_ASSIGNMENTS) max_i = MAX_STATION_ZONE_ASSIGNMENTS;
    for (int i = 0; i < max_i; ++i) {
        if (g_station_zone_override_id[i] > 0) n++;
    }
    return n;
}

static void apply_station_zone_assignment(int station_i, int zone_id, const char *zone_name) {
    init_station_zone_assignments();
    if (station_i < 0 || station_i >= (int)g_grid.station_count) return;
    if (station_i >= MAX_STATION_ZONE_ASSIGNMENTS) return;
    if (zone_id < 1) zone_id = 1;
    if (zone_id > 999) zone_id = 999;

    g_station_zone_override_id[station_i] = zone_id;
    snprintf(g_station_zone_override_name[station_i], ZONE_ASSIGN_NAME_LEN, "%s",
             (zone_name && zone_name[0]) ? zone_name : forecast_zone_name_from_id(zone_id));

    if (g_grid.station_id) {
        g_grid.station_id[station_i] = zone_id;
    }

    set_status("Assigned Obs #%d to zone %d: %s",
               station_i + 1, zone_id, g_station_zone_override_name[station_i]);
}

static void shift_zone_assign_clear_batch(void) {
    memset(g_shift_zone_assign_selected, 0, sizeof(g_shift_zone_assign_selected));
    g_shift_zone_assign_count = 0;
}

static void shift_zone_assign_begin_if_needed(void) {
    if (!g_shift_zone_assign_active) {
        g_shift_zone_assign_active = 1;
        shift_zone_assign_clear_batch();
    }
}

static int shift_zone_assign_add_station(int station_i) {
    if (station_i < 0 || station_i >= (int)g_grid.station_count) return 0;
    if (station_i >= MAX_STATION_ZONE_ASSIGNMENTS) return 0;

    shift_zone_assign_begin_if_needed();

    if (g_shift_zone_assign_selected[station_i]) return 0;
    if (g_shift_zone_assign_count >= MAX_STATION_ZONE_ASSIGNMENTS) return 0;

    g_shift_zone_assign_selected[station_i] = 1u;
    g_shift_zone_assign_station_list[g_shift_zone_assign_count++] = station_i;
    return 1;
}

static void apply_station_zone_assignment_batch(int zone_id, const char *zone_name) {
    int n = g_shift_zone_assign_count;
    for (int i = 0; i < n; ++i) {
        apply_station_zone_assignment(g_shift_zone_assign_station_list[i], zone_id, zone_name);
    }
    set_status("Assigned %d station(s) to zone %d: %s", n, zone_id,
               (zone_name && zone_name[0]) ? zone_name : forecast_zone_name_from_id(zone_id));
}

static void draw_hover_station_tooltip(HDC hdc, HWND hwnd) {
    if (g_hover_station_i < 0 || g_mouse_screen_x < 0 || g_mouse_screen_y < 0) return;

    int gx = 0, gy = 0, sxg = 0, syg = 0, region_id = 0, arrival_s = 0;
    float live_raw = 0.0f, live_pos = 0.0f, peak_pos = 0.0f;
    if (!get_station_info(g_hover_station_i, &gx, &gy, &sxg, &syg, &region_id,
                          &live_raw, &live_pos, &peak_pos, &arrival_s)) return;

    RECT rc;
    GetClientRect(hwnd, &rc);
    int map_right = rc.right - PANEL_W;
    if (map_right < rc.right / 2) map_right = rc.right;

    const int box_w = 315;
    const int box_h = 150;
    int bx = g_mouse_screen_x + 16;
    int by = g_mouse_screen_y + 16;
    if (bx + box_w > map_right - 8) bx = g_mouse_screen_x - box_w - 16;
    if (by + box_h > rc.bottom - 8) by = g_mouse_screen_y - box_h - 16;
    if (bx < 8) bx = 8;
    if (by < 8) by = 8;

    RECT box = { bx, by, bx + box_w, by + box_h };
    HBRUSH bg = CreateSolidBrush(RGB(24, 24, 30));
    FillRect(hdc, &box, bg);
    DeleteObject(bg);
    HPEN border = CreatePen(PS_SOLID, 1, RGB(255, 245, 170));
    HPEN old_pen = (HPEN)SelectObject(hdc, border);
    HBRUSH old_brush = (HBRUSH)SelectObject(hdc, GetStockObject(NULL_BRUSH));
    Rectangle(hdc, box.left, box.top, box.right, box.bottom);
    SelectObject(hdc, old_brush);
    SelectObject(hdc, old_pen);
    DeleteObject(border);

    char buf[512];
    int tx = bx + 10;
    int ty = by + 8;
    SetBkMode(hdc, TRANSPARENT);
    SetTextColor(hdc, RGB(255, 245, 170));
    region_id = station_zone_id_for_display(g_hover_station_i);
    snprintf(buf, sizeof(buf), "Obs #%d  station_id %d%s", g_hover_station_i + 1, region_id,
             (GetKeyState(VK_SHIFT) & 0x8000) ? "  [SHIFT batch]" : "");
    draw_text_line(hdc, tx, &ty, buf);
    SetTextColor(hdc, RGB(235, 235, 235));
    snprintf(buf, sizeof(buf), "%s", station_zone_name_for_display(g_hover_station_i));
    draw_text_line(hdc, tx, &ty, buf);
    snprintf(buf, sizeof(buf), "Grid %d,%d  sample %d,%d", gx, gy, sxg, syg);
    draw_text_line(hdc, tx, &ty, buf);
    snprintf(buf, sizeof(buf), "Live level %.4f m  %.2f cm", live_raw, live_raw * 100.0f);
    draw_text_line(hdc, tx, &ty, buf);
    snprintf(buf, sizeof(buf), "Live rise %.4f m / Peak rise %.4f m", live_pos, peak_pos);
    draw_text_line(hdc, tx, &ty, buf);
    if (g_coast_marker_use_zone_max) {
        float zmax = 0.0f;
        int z = station_zone_id_for_display(g_hover_station_i);
        for (uint32_t zi = 0; zi < g_grid.station_count; ++zi) {
            if (!station_is_enabled(&g_grid, zi)) continue;
            if (station_zone_id_for_display((int)zi) == z && g_sim.station_max_m[zi] > zmax) zmax = g_sim.station_max_m[zi];
        }
        snprintf(buf, sizeof(buf), "Zone marker value %.4f m", zmax);
        draw_text_line(hdc, tx, &ty, buf);
    }
    char timebuf[64];
    format_seconds_gui(arrival_s, timebuf, sizeof(timebuf));
    snprintf(buf, sizeof(buf), "Arrival >=5cm: %s   Bin: %s", timebuf, tsunami_height_color_bin(peak_pos));
    draw_text_line(hdc, tx, &ty, buf);
}

static int grid_to_screen_x_float(float gx) {
    float draw_x = g_display_flip_x ? ((float)g_grid.width - 1.0f - gx) : gx;
    return g_view_x + (int)((double)draw_x * (double)g_view_w / (double)g_grid.width);
}

static int grid_to_screen_y_float(float gy) {
    float draw_y = g_display_flip_y ? ((float)g_grid.height - 1.0f - gy) : gy;
    return g_view_y + (int)((double)draw_y * (double)g_view_h / (double)g_grid.height);
}

static void draw_reverse_fault_preview(HDC hdc) {
    if (!g_source_profile_mode) return;
    if (g_mouse_grid_x < 0 || g_mouse_grid_y < 0) return;

    float sx, sy, nx, ny;
    reverse_fault_basis(&sx, &sy, &nx, &ny);
    if (g_strike_slip_mix >= 0.50f) {
        /* For mostly strike-slip sources, preview the corrected strike-slip trace. */
        strike_slip_fault_basis(&sx, &sy, &nx, &ny);
    }
    float len = (float)reverse_profile_radius_cells_at_y((float)g_mouse_grid_y) * 3.0f;
    if (len < 12.0f) len = 12.0f;

    float cx = (float)g_mouse_grid_x;
    float cy = (float)g_mouse_grid_y;
    int x1 = grid_to_screen_x_float(cx - sx * len);
    int y1 = grid_to_screen_y_float(cy - sy * len);
    int x2 = grid_to_screen_x_float(cx + sx * len);
    int y2 = grid_to_screen_y_float(cy + sy * len);

    HPEN strike_pen = CreatePen(PS_SOLID, 2, RGB(255, 220, 40));
    HPEN old_pen = (HPEN)SelectObject(hdc, strike_pen);
    MoveToEx(hdc, x1, y1, NULL);
    LineTo(hdc, x2, y2);
    SelectObject(hdc, old_pen);
    DeleteObject(strike_pen);

    int cxp = grid_to_screen_x_float(cx);
    int cyp = grid_to_screen_y_float(cy);
    int nxp = grid_to_screen_x_float(cx + nx * len * 0.95f);
    int nyp = grid_to_screen_y_float(cy + ny * len * 0.95f);
    HPEN normal_pen = CreatePen(PS_SOLID, 2, RGB(80, 170, 255));
    old_pen = (HPEN)SelectObject(hdc, normal_pen);
    MoveToEx(hdc, cxp, cyp, NULL);
    LineTo(hdc, nxp, nyp);

    /* Small V arrowhead at the subsidence-side end, calculated in screen space
       so it remains correct even when the display is flipped. */
    float dxp = (float)(nxp - cxp);
    float dyp = (float)(nyp - cyp);
    float plen = sqrtf(dxp * dxp + dyp * dyp);
    if (plen < 1.0f) plen = 1.0f;
    float ux_screen = dxp / plen;
    float uy_screen = dyp / plen;
    float px_screen = -uy_screen;
    float py_screen = ux_screen;
    float ah = 8.0f;
    float ax1 = (float)nxp - ux_screen * ah + px_screen * ah * 0.55f;
    float ay1 = (float)nyp - uy_screen * ah + py_screen * ah * 0.55f;
    float ax2 = (float)nxp - ux_screen * ah - px_screen * ah * 0.55f;
    float ay2 = (float)nyp - uy_screen * ah - py_screen * ah * 0.55f;
    MoveToEx(hdc, nxp, nyp, NULL);
    LineTo(hdc, (int)ax1, (int)ay1);
    MoveToEx(hdc, nxp, nyp, NULL);
    LineTo(hdc, (int)ax2, (int)ay2);
    SelectObject(hdc, old_pen);
    DeleteObject(normal_pen);
}


static void draw_time_series_selected_highlight(HDC hdc) {
    if (!g_show_time_series_markers || g_ts_rec_count == 0) return;
    HPEN pen = CreatePen(PS_SOLID, 1, RGB(60, 240, 255));
    HPEN active_pen = CreatePen(PS_SOLID, 2, RGB(255, 255, 255));
    HBRUSH old_brush = (HBRUSH)SelectObject(hdc, GetStockObject(NULL_BRUSH));
    HPEN old_pen = (HPEN)SelectObject(hdc, pen);
    for (size_t i = 0; i < g_ts_rec_count; ++i) {
        int gx = 0, gy = 0;
        if (!get_station_info(g_ts_recorders[i].station_i, &gx, &gy, NULL, NULL, NULL, NULL, NULL, NULL, NULL)) continue;
        int sx = grid_cell_screen_center_x(gx);
        int sy = grid_cell_screen_center_y(gy);
        int hs = scaled_marker_half_size_px(4);
        SelectObject(hdc, (g_ts_recorders[i].station_i == g_ts_station_i) ? active_pen : pen);
        Ellipse(hdc, sx - hs, sy - hs, sx + hs + 1, sy + hs + 1);
    }
    SelectObject(hdc, old_brush);
    SelectObject(hdc, old_pen);
    DeleteObject(active_pen);
    DeleteObject(pen);
}

static COLORREF stamp_fault_color(int fault_id) {
    static const COLORREF colors[] = {
        RGB(255, 190, 60), RGB(255, 80, 200), RGB(80, 220, 255), RGB(120, 255, 120),
        RGB(255, 120, 80), RGB(180, 140, 255), RGB(255, 255, 120), RGB(120, 220, 180)
    };
    if (fault_id <= 0) return RGB(255, 255, 80);
    return colors[(fault_id - 1) & 7];
}

static void draw_source_markers(HDC hdc) {
    if (!g_show_source_markers) return;
    HPEN old_pen = (HPEN)SelectObject(hdc, GetStockObject(DC_PEN));
    HBRUSH old_brush = (HBRUSH)SelectObject(hdc, GetStockObject(DC_BRUSH));
    HPEN selected_pen = CreatePen(PS_SOLID, 2, RGB(255, 80, 255));
    HPEN tune_pen = CreatePen(PS_DOT, 1, RGB(255, 255, 255));

    for (size_t i = 0; i < g_sources.count; ++i) {
        float h = g_sources.items[i].height_m;
        int fault_id = g_sources.items[i].fault_id;
        int selected = node_tune_is_selected((int)i);
        int primary = source_index_is_valid(g_selected_source_i) && ((int)i == g_selected_source_i);
        if (selected) {
            SelectObject(hdc, selected_pen);
            SetDCBrushColor(hdc, primary ? RGB(255, 80, 255) : RGB(220, 80, 255));
        } else {
            SelectObject(hdc, GetStockObject(DC_PEN));
            if (fabsf(h) < 0.0005f) {
                /* Footprint-only land/zero-height source cells are moment-area
                   markers, not uplift.  Draw them neutral so they do not look like
                   forced land uplift. */
                SetDCPenColor(hdc, RGB(180, 180, 180));
                SetDCBrushColor(hdc, RGB(120, 120, 120));
            } else if (h > 0.0f) {
                SetDCPenColor(hdc, RGB(255, 255, 80));
                SetDCBrushColor(hdc, RGB(255, 230, 40));
            } else {
                SetDCPenColor(hdc, RGB(80, 220, 255));
                SetDCBrushColor(hdc, RGB(40, 150, 255));
            }
            if (fault_id > 0) {
                SetDCPenColor(hdc, stamp_fault_color(fault_id));
            }
        }
        int sx = grid_cell_screen_center_x(g_sources.items[i].grid_x);
        int sy = grid_cell_screen_center_y(g_sources.items[i].grid_y);
        int hs = scaled_marker_half_size_px(selected ? (primary ? 5 : 4) : 3);
        Ellipse(hdc, sx - hs, sy - hs, sx + hs + 1, sy + hs + 1);
        if (selected) {
            HBRUSH null_brush = (HBRUSH)SelectObject(hdc, GetStockObject(NULL_BRUSH));
            Ellipse(hdc, sx - hs - 4, sy - hs - 4, sx + hs + 5, sy + hs + 5);
            if (primary) Ellipse(hdc, sx - hs - 7, sy - hs - 7, sx + hs + 8, sy + hs + 8);
            SelectObject(hdc, null_brush);
        }
    }

    if (g_node_tune_mode && g_sources.count > 0) {
        SelectObject(hdc, tune_pen);
        HBRUSH null_brush = (HBRUSH)SelectObject(hdc, GetStockObject(NULL_BRUSH));
        for (size_t i = 0; i < g_sources.count; ++i) {
            if (node_tune_is_selected((int)i)) continue;
            int sx = grid_cell_screen_center_x(g_sources.items[i].grid_x);
            int sy = grid_cell_screen_center_y(g_sources.items[i].grid_y);
            int hs = scaled_marker_half_size_px(5);
            Rectangle(hdc, sx - hs, sy - hs, sx + hs + 1, sy + hs + 1);
        }
        SelectObject(hdc, null_brush);
    }

    if (g_node_tune_mode && g_node_tune_box_select_active) {
        HPEN box_pen = CreatePen(PS_DASH, 1, RGB(255, 80, 255));
        HPEN prev_pen = (HPEN)SelectObject(hdc, box_pen);
        HBRUSH null_brush = (HBRUSH)SelectObject(hdc, GetStockObject(NULL_BRUSH));
        int x0 = g_node_tune_box_start_x;
        int y0 = g_node_tune_box_start_y;
        int x1 = g_node_tune_box_cur_x;
        int y1 = g_node_tune_box_cur_y;
        if (x0 > x1) { int t = x0; x0 = x1; x1 = t; }
        if (y0 > y1) { int t = y0; y0 = y1; y1 = t; }
        Rectangle(hdc, x0, y0, x1 + 1, y1 + 1);
        SelectObject(hdc, null_brush);
        SelectObject(hdc, prev_pen);
        DeleteObject(box_pen);
    }

    SelectObject(hdc, old_pen);
    SelectObject(hdc, old_brush);
    DeleteObject(selected_pen);
    DeleteObject(tune_pen);
}



static void draw_wave_color_legend(HDC hdc) {
    const int box_w = 18;
    const int box_h = 14;
    const int gap = 4;
    const int labels = 9;

    float max_m = g_wave_color_max_m;
    if (max_m < 0.5f) max_m = 0.5f;

    float heights[9];
    char texts[9][24];

    if (g_wave_layer_mode != WAVE_LAYER_LIVE) {
        heights[0] = 0.0f;
        heights[1] = max_m * 0.5f / 20.0f;
        heights[2] = max_m * 1.0f / 20.0f;
        heights[3] = max_m * 2.0f / 20.0f;
        heights[4] = max_m * 5.0f / 20.0f;
        heights[5] = max_m * 10.0f / 20.0f;
        heights[6] = max_m * 15.0f / 20.0f;
        heights[7] = max_m;
        heights[8] = max_m;

        snprintf(texts[0], sizeof(texts[0]), "0m");
        snprintf(texts[1], sizeof(texts[1]), "%.2gm", heights[1]);
        snprintf(texts[2], sizeof(texts[2]), "%.2gm", heights[2]);
        snprintf(texts[3], sizeof(texts[3]), "%.2gm", heights[3]);
        snprintf(texts[4], sizeof(texts[4]), "%.2gm", heights[4]);
        snprintf(texts[5], sizeof(texts[5]), "%.2gm", heights[5]);
        snprintf(texts[6], sizeof(texts[6]), "%.2gm", heights[6]);
        snprintf(texts[7], sizeof(texts[7]), "%.2gm", heights[7]);
        snprintf(texts[8], sizeof(texts[8]), "max");
    } else {
        heights[0] = -1.0f;
        heights[1] = 0.0f;
        heights[2] = max_m * 0.5f / 20.0f;
        heights[3] = max_m * 1.0f / 20.0f;
        heights[4] = max_m * 2.0f / 20.0f;
        heights[5] = max_m * 5.0f / 20.0f;
        heights[6] = max_m * 10.0f / 20.0f;
        heights[7] = max_m * 15.0f / 20.0f;
        heights[8] = max_m;

        snprintf(texts[0], sizeof(texts[0]), "-1m");
        snprintf(texts[1], sizeof(texts[1]), "0m");
        snprintf(texts[2], sizeof(texts[2]), "%.2gm", heights[2]);
        snprintf(texts[3], sizeof(texts[3]), "%.2gm", heights[3]);
        snprintf(texts[4], sizeof(texts[4]), "%.2gm", heights[4]);
        snprintf(texts[5], sizeof(texts[5]), "%.2gm", heights[5]);
        snprintf(texts[6], sizeof(texts[6]), "%.2gm", heights[6]);
        snprintf(texts[7], sizeof(texts[7]), "%.2gm", heights[7]);
        snprintf(texts[8], sizeof(texts[8]), "%.2gm", heights[8]);
    }

    int x = g_view_x + 12;
    int y = g_view_y + 12;
    int panel_w = 124;
    int panel_h = 26 + labels * (box_h + gap) + 4;

    RECT bg = {x - 8, y - 8, x + panel_w, y + panel_h};
    HBRUSH bg_brush = CreateSolidBrush(RGB(8, 16, 30));
    FillRect(hdc, &bg, bg_brush);
    DeleteObject(bg_brush);

    HPEN border = CreatePen(PS_SOLID, 1, RGB(180, 190, 205));
    HGDIOBJ old_pen = SelectObject(hdc, border);
    HGDIOBJ old_brush = SelectObject(hdc, GetStockObject(NULL_BRUSH));
    Rectangle(hdc, bg.left, bg.top, bg.right, bg.bottom);
    SelectObject(hdc, old_brush);
    SelectObject(hdc, old_pen);
    DeleteObject(border);

    SetBkMode(hdc, TRANSPARENT);
    SetTextColor(hdc, RGB(235, 235, 235));
    int ty = y - 2;
    draw_text_line(hdc, x, &ty, g_wave_layer_mode == WAVE_LAYER_MAX_WITH_ARRIVAL ? "Max + arrival" : (g_wave_layer_mode == WAVE_LAYER_MAX_POSITIVE ? "Max wave scale" : "Wave scale"));

    HBRUSH old_dc_brush = (HBRUSH)SelectObject(hdc, GetStockObject(DC_BRUSH));
    for (int i = 0; i < labels; ++i) {
        int row_y = y + 22 + i * (box_h + gap);
        uint32_t c = tsunami_wave_color_log(heights[i]);
        SetDCBrushColor(hdc, RGB((c >> 16) & 0xffu, (c >> 8) & 0xffu, c & 0xffu));
        Rectangle(hdc, x, row_y, x + box_w, row_y + box_h);
        int text_y = row_y - 2;
        draw_text_line(hdc, x + box_w + 8, &text_y, texts[i]);
    }
    SelectObject(hdc, old_dc_brush);
}


static void draw_scene_to_hdc(HDC hdc, HWND hwnd) {
    RECT rc;
    GetClientRect(hwnd, &rc);

    HBRUSH bg = CreateSolidBrush(RGB(18, 18, 22));
    FillRect(hdc, &rc, bg);
    DeleteObject(bg);

    compute_viewport(hwnd);
    render_to_pixel_buffer();
    StretchDIBits(hdc,
                  g_view_x, g_view_y, g_view_w, g_view_h,
                  0, 0, g_grid.width, g_grid.height,
                  g_pixels, &g_bmi, DIB_RGB_COLORS, SRCCOPY);

    if (!g_jesim_transmit_mode) {
        draw_coast_height_markers(hdc);
        draw_time_series_selected_highlight(hdc);
        draw_hover_station_highlight(hdc);
        draw_hover_station_tooltip(hdc, hwnd);
    }
    draw_source_markers(hdc);

    RECT panel = { rc.right - PANEL_W, 0, rc.right, rc.bottom };
    HBRUSH panel_bg = CreateSolidBrush(RGB(32, 32, 38));
    FillRect(hdc, &panel, panel_bg);
    DeleteObject(panel_bg);
    draw_panel(hdc, hwnd);
}


static void load_source_csv_dialog(HWND hwnd) {
    char file_name[MAX_PATH] = "";
    OPENFILENAMEA ofn;
    memset(&ofn, 0, sizeof(ofn));
    ofn.lStructSize = sizeof(ofn);
    ofn.hwndOwner = hwnd;
    ofn.lpstrFilter = "CSV source files\0*.csv\0All files\0*.*\0";
    ofn.lpstrFile = file_name;
    ofn.nMaxFile = MAX_PATH;
    ofn.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR;
    ofn.lpstrTitle = "Load source CSV";
    if (GetOpenFileNameA(&ofn)) {
        size_t before = g_sources.count;
        source_undo_push("load source CSV");
        if (load_sources_csv(&g_sources, file_name)) {
            for (size_t i = before; i < g_sources.count; ++i) {
                source_apply_current_fault_metadata(&g_sources.items[i]);
                sanitize_source_node_slip(&g_sources.items[i]);
            }
            reset_state_preserve_sources();
            set_status("Loaded %zu source(s) from CSV.", g_sources.count - before);
        } else {
            set_status("Failed to load source CSV.");
        }
        InvalidateRect(hwnd, NULL, FALSE);
    }
}


static void make_suffixed_path(const char *path, const char *suffix, char *out, size_t out_size) {
    const char *slash1 = strrchr(path, '\\');
    const char *slash2 = strrchr(path, '/');
    const char *slash = slash1 > slash2 ? slash1 : slash2;
    const char *base = slash ? slash + 1 : path;
    const char *dot = strrchr(base, '.');

    if (dot) {
        size_t prefix_len = (size_t)(dot - path);
        snprintf(out, out_size, "%.*s%s%s", (int)prefix_len, path, suffix, dot);
    } else {
        snprintf(out, out_size, "%s%s.csv", path, suffix);
    }
}

static void png_write_be32(FILE *fp, uint32_t v) {
    fputc((int)((v >> 24) & 0xffu), fp);
    fputc((int)((v >> 16) & 0xffu), fp);
    fputc((int)((v >> 8) & 0xffu), fp);
    fputc((int)(v & 0xffu), fp);
}

static uint32_t png_crc32_update(uint32_t crc, const unsigned char *data, size_t len) {
    crc = ~crc;
    for (size_t i = 0; i < len; ++i) {
        crc ^= (uint32_t)data[i];
        for (int k = 0; k < 8; ++k) {
            crc = (crc & 1u) ? (0xedb88320u ^ (crc >> 1)) : (crc >> 1);
        }
    }
    return ~crc;
}

static uint32_t png_adler32(const unsigned char *data, size_t len) {
    uint32_t a = 1u;
    uint32_t b = 0u;
    for (size_t i = 0; i < len; ++i) {
        a = (a + data[i]) % 65521u;
        b = (b + a) % 65521u;
    }
    return (b << 16) | a;
}

static int png_write_chunk(FILE *fp, const char type[4], const unsigned char *data, uint32_t len) {
    png_write_be32(fp, len);
    if (fwrite(type, 1, 4, fp) != 4) return 0;
    if (len > 0 && fwrite(data, 1, len, fp) != len) return 0;

    uint32_t crc = 0xffffffffu;
    crc = png_crc32_update(~crc, (const unsigned char *)type, 4);
    if (len > 0) crc = png_crc32_update(crc, data, len);
    png_write_be32(fp, crc);
    return 1;
}

static int write_png_rgb24_uncompressed(const char *path, const unsigned char *rgb, uint32_t w, uint32_t h) {
    FILE *fp = fopen(path, "wb");
    if (!fp) return 0;

    const unsigned char sig[8] = {137, 80, 78, 71, 13, 10, 26, 10};
    if (fwrite(sig, 1, 8, fp) != 8) { fclose(fp); return 0; }

    unsigned char ihdr[13];
    ihdr[0] = (unsigned char)((w >> 24) & 0xffu);
    ihdr[1] = (unsigned char)((w >> 16) & 0xffu);
    ihdr[2] = (unsigned char)((w >> 8) & 0xffu);
    ihdr[3] = (unsigned char)(w & 0xffu);
    ihdr[4] = (unsigned char)((h >> 24) & 0xffu);
    ihdr[5] = (unsigned char)((h >> 16) & 0xffu);
    ihdr[6] = (unsigned char)((h >> 8) & 0xffu);
    ihdr[7] = (unsigned char)(h & 0xffu);
    ihdr[8] = 8;  /* bit depth */
    ihdr[9] = 2;  /* truecolor RGB */
    ihdr[10] = 0; /* compression */
    ihdr[11] = 0; /* filter */
    ihdr[12] = 0; /* interlace */

    if (!png_write_chunk(fp, "IHDR", ihdr, 13)) { fclose(fp); return 0; }

    size_t row_bytes = (size_t)w * 3u;
    size_t raw_len = (row_bytes + 1u) * (size_t)h;
    unsigned char *raw = (unsigned char *)malloc(raw_len);
    if (!raw) { fclose(fp); return 0; }

    for (uint32_t y = 0; y < h; ++y) {
        unsigned char *row = raw + (size_t)y * (row_bytes + 1u);
        row[0] = 0; /* PNG filter type 0 */
        memcpy(row + 1, rgb + (size_t)y * row_bytes, row_bytes);
    }

    size_t blocks = (raw_len + 65534u) / 65535u;
    size_t z_len = 2u + raw_len + blocks * 5u + 4u;
    unsigned char *z = (unsigned char *)malloc(z_len);
    if (!z) { free(raw); fclose(fp); return 0; }

    size_t zp = 0;
    z[zp++] = 0x78; /* zlib CMF */
    z[zp++] = 0x01; /* zlib FLG: no compression/fastest */

    size_t rp = 0;
    while (rp < raw_len) {
        size_t chunk = raw_len - rp;
        if (chunk > 65535u) chunk = 65535u;
        int final = (rp + chunk >= raw_len);
        z[zp++] = (unsigned char)(final ? 1 : 0); /* stored deflate block */
        z[zp++] = (unsigned char)(chunk & 0xffu);
        z[zp++] = (unsigned char)((chunk >> 8) & 0xffu);
        uint16_t nlen = (uint16_t)~((uint16_t)chunk);
        z[zp++] = (unsigned char)(nlen & 0xffu);
        z[zp++] = (unsigned char)((nlen >> 8) & 0xffu);
        memcpy(z + zp, raw + rp, chunk);
        zp += chunk;
        rp += chunk;
    }

    uint32_t adler = png_adler32(raw, raw_len);
    z[zp++] = (unsigned char)((adler >> 24) & 0xffu);
    z[zp++] = (unsigned char)((adler >> 16) & 0xffu);
    z[zp++] = (unsigned char)((adler >> 8) & 0xffu);
    z[zp++] = (unsigned char)(adler & 0xffu);

    int ok = png_write_chunk(fp, "IDAT", z, (uint32_t)zp) &&
             png_write_chunk(fp, "IEND", NULL, 0);

    free(z);
    free(raw);
    fclose(fp);
    return ok;
}

static int write_max_wave_png(const char *path, int include_arrival_contours) {
    const uint32_t W = g_grid.width;
    const uint32_t H = g_grid.height;
    unsigned char *rgb = (unsigned char *)malloc((size_t)W * (size_t)H * 3u);
    if (!rgb) return 0;

    for (uint32_t y = 0; y < H; ++y) {
        uint32_t gy = g_display_flip_y ? (H - 1u - y) : y;
        for (uint32_t x = 0; x < W; ++x) {
            uint32_t gx = g_display_flip_x ? (W - 1u - x) : x;
            uint32_t c = max_wave_rgb_for_grid_cell(gx, gy, include_arrival_contours);
            size_t p = ((size_t)y * W + x) * 3u;
            rgb[p + 0] = (unsigned char)((c >> 16) & 0xffu);
            rgb[p + 1] = (unsigned char)((c >> 8) & 0xffu);
            rgb[p + 2] = (unsigned char)(c & 0xffu);
        }
    }

    int ok = write_png_rgb24_uncompressed(path, rgb, W, H);
    free(rgb);
    return ok;
}

static void save_max_wave_png_dialog(HWND hwnd) {
    char file_name[MAX_PATH] = "max_wave_height.png";
    OPENFILENAMEA ofn;
    memset(&ofn, 0, sizeof(ofn));
    ofn.lStructSize = sizeof(ofn);
    ofn.hwndOwner = hwnd;
    ofn.lpstrFilter = "PNG image\0*.png\0All files\0*.*\0";
    ofn.lpstrFile = file_name;
    ofn.nMaxFile = MAX_PATH;
    ofn.Flags = OFN_OVERWRITEPROMPT | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR;
    ofn.lpstrTitle = "Save max wave PNG";
    if (GetSaveFileNameA(&ofn)) {
        int with_arrival = (g_wave_layer_mode == WAVE_LAYER_MAX_WITH_ARRIVAL);
        if (write_max_wave_png(file_name, with_arrival)) {
            set_status("Saved %s PNG: %s", with_arrival ? "max-wave + arrival-contour" : "max-wave", file_name);
        } else {
            set_status("Failed to save max-wave PNG.");
        }
        InvalidateRect(hwnd, NULL, FALSE);
    }
}


static void write_preset_param(FILE *fp, const char *key, double value) {
    fprintf(fp, "PARAM,%s,%.12g\n", key, value);
}

static int preset_apply_param(const char *key, double value) {
    if (strcmp(key, "new_source_height_m") == 0) g_new_source_height_m = (float)value;
    else if (strcmp(key, "new_source_delay_s") == 0) g_new_source_delay_s = (int)lround(value);
    else if (strcmp(key, "fault_width_km") == 0) set_fault_width_km_value((float)value);
    else if (strcmp(key, "brush_radius_cells") == 0) {
        g_brush_radius_cells = clampi_gui((int)lround(value), 2, 120);
        set_fault_width_km_value(legacy_brush_radius_cells_to_fault_width_km(g_brush_radius_cells));
    }
    else if (strcmp(key, "subsidence_ratio") == 0) g_subsidence_ratio = clampf_gui((float)value, 0.0f, 1.0f);
    else if (strcmp(key, "fault_dip_deg") == 0) g_fault_dip_deg = clampf_gui((float)value, 5.0f, 60.0f);
    else if (strcmp(key, "strike_slip_mix") == 0) g_strike_slip_mix = clampf_gui((float)value, 0.0f, 1.0f);
    else if (strcmp(key, "fault_strike_deg") == 0) g_fault_strike_deg = normalize_deg_gui((float)value);
    else if (strcmp(key, "strike_smoothing_strength") == 0) g_strike_smoothing_strength = clampf_gui((float)value, 0.0f, 5.0f);
    else if (strcmp(key, "source_depth_km") == 0) g_source_depth_km = clampf_gui((float)value, 0.0f, 700.0f);
    else if (strcmp(key, "rupture_velocity_km_s") == 0) g_rupture_velocity_km_s = clampf_gui((float)value, 0.10f, 8.00f);
    else if (strcmp(key, "rupture_delay_cursor_s") == 0) g_rupture_delay_cursor_s = (float)value;
    else if (strcmp(key, "source_profile_mode") == 0) g_source_profile_mode = ((int)lround(value)) ? 1 : 0;
    else if (strcmp(key, "strike_slip_sense") == 0) g_strike_slip_sense = ((int)lround(value)) < 0 ? -1 : 1;
    else if (strcmp(key, "bilateral_rupture") == 0) g_bilateral_rupture = ((int)lround(value)) ? 1 : 0;
    else if (strcmp(key, "wave_display_coeff") == 0) g_wave_display_coeff = clampf_gui((float)value, 0.05f, 20.0f);
    else if (strcmp(key, "wave_overlay_alpha") == 0) g_wave_overlay_alpha = clampf_gui((float)value, 0.0f, 1.0f);
    else if (strcmp(key, "fault_stamp_length_km") == 0) g_fault_stamp_length_km = normalized_stamp_length_km((float)value);
    else if (strcmp(key, "subsidence_side") == 0) g_subsidence_side = ((int)lround(value)) < 0 ? -1 : 1;
    else return 0;
    return 1;
}

static int save_current_preset_file(const char *path) {
    FILE *fp = fopen(path, "wb");
    if (!fp) return 0;

    fputs("\xEF\xBB\xBF", fp);
    fprintf(fp, "# tsunami_gui_preset_v1\n");
    fprintf(fp, "# rows: PARAM,key,value or SOURCE,grid_x,grid_y,height_m,delay_s,slip_m[,dip_deg,moment_area_factor,fault_id,patch_id,strike_deg,depth_km,width_km,length_km]\n");

    write_preset_param(fp, "new_source_height_m", g_new_source_height_m);
    write_preset_param(fp, "new_source_delay_s", g_new_source_delay_s);
    write_preset_param(fp, "brush_radius_cells", reverse_profile_radius_cells()); /* legacy compatibility */
    write_preset_param(fp, "fault_width_km", current_fault_width_km());
    write_preset_param(fp, "subsidence_ratio", g_subsidence_ratio);
    write_preset_param(fp, "fault_dip_deg", g_fault_dip_deg);
    write_preset_param(fp, "strike_slip_mix", g_strike_slip_mix);
    write_preset_param(fp, "fault_strike_deg", g_fault_strike_deg);
    write_preset_param(fp, "strike_smoothing_strength", g_strike_smoothing_strength);
    write_preset_param(fp, "source_depth_km", g_source_depth_km);
    write_preset_param(fp, "rupture_velocity_km_s", g_rupture_velocity_km_s);
    write_preset_param(fp, "rupture_delay_cursor_s", g_rupture_delay_cursor_s);
    write_preset_param(fp, "source_profile_mode", g_source_profile_mode);
    write_preset_param(fp, "strike_slip_sense", g_strike_slip_sense);
    write_preset_param(fp, "bilateral_rupture", g_bilateral_rupture);
    write_preset_param(fp, "wave_display_coeff", g_wave_display_coeff);
    write_preset_param(fp, "wave_overlay_alpha", g_wave_overlay_alpha);
    write_preset_param(fp, "fault_stamp_length_km", g_fault_stamp_length_km);
    write_preset_param(fp, "subsidence_side", g_subsidence_side);

    fprintf(fp, "SOURCE_HEADER,grid_x,grid_y,height_m,delay_s,slip_m,dip_deg,moment_area_factor,fault_id,patch_id,strike_deg,depth_km,width_km,length_km\n");
    for (size_t i = 0; i < g_sources.count; ++i) {
        const SourceSpec *src = &g_sources.items[i];
        fprintf(fp, "SOURCE,%d,%d,%.9g,%d,%.9g,%.9g,%.9g,%d,%d,%.9g,%.9g,%.9g,%.9g\n",
                src->grid_x, src->grid_y, src->height_m, src->delay_s,
                sanitize_source_slip_for_height(src->height_m, src->slip_m),
                source_node_stamp_dip_deg(src), source_node_moment_area_factor(src),
                src->fault_id, src->fault_patch_id, src->stamp_strike_deg,
                src->stamp_depth_km, src->stamp_width_km, src->stamp_length_km);
    }

    fclose(fp);
    return 1;
}

static int load_preset_file(const char *path, size_t *loaded_sources) {
    FILE *fp = fopen(path, "rb");
    if (!fp) return 0;

    SourceList new_sources;
    memset(&new_sources, 0, sizeof(new_sources));

    char line[512];
    while (fgets(line, sizeof(line), fp)) {
        char *p = line;
        if ((unsigned char)p[0] == 0xef && (unsigned char)p[1] == 0xbb && (unsigned char)p[2] == 0xbf) p += 3;
        while (*p == ' ' || *p == '\t') p++;
        if (*p == '\0' || *p == '\n' || *p == '#') continue;

        if (strncmp(p, "PARAM,", 6) == 0) {
            char key[96];
            double value = 0.0;
            if (sscanf(p + 6, " %95[^,] , %lf", key, &value) == 2) {
                char *k = key;
                while (*k == ' ' || *k == '\t') k++;
                char *end = k + strlen(k);
                while (end > k && (end[-1] == ' ' || end[-1] == '\t')) *--end = '\0';
                preset_apply_param(k, value);
            }
        } else if (strncmp(p, "SOURCE,", 7) == 0) {
            int x = 0, y = 0, delay = 0, fault_id = 0, patch_id = 0;
            float h = 0.0f, slip = 0.0f, dip_deg = 0.0f, area_factor = 0.0f;
            float strike_deg = 0.0f, depth_km = 0.0f, width_km = 0.0f, length_km = 0.0f;
            int n = sscanf(p + 7, " %d , %d , %f , %d , %f , %f , %f , %d , %d , %f , %f , %f , %f",
                           &x, &y, &h, &delay, &slip, &dip_deg, &area_factor,
                           &fault_id, &patch_id, &strike_deg, &depth_km, &width_km, &length_km);
            if (n >= 4) {
                source_list_append(&new_sources, x, y, h, delay);
                if (n >= 5) source_set_last_slip_m(&new_sources, slip);
                else source_set_last_slip_m(&new_sources, h > SOURCE_SLIP_HEIGHT_EPS_M ? h : 0.0f);
                if (new_sources.count > 0) {
                    SourceSpec *last = &new_sources.items[new_sources.count - 1];
                    if (n >= 6 && isfinite(dip_deg) && dip_deg >= 5.0f && dip_deg <= 60.0f) {
                        last->stamp_dip_deg = dip_deg;
                    }
                    if (n >= 7 && isfinite(area_factor) && area_factor > 0.01f && area_factor < 100.0f) {
                        last->moment_area_factor = area_factor;
                    }
                    if (n >= 8) last->fault_id = fault_id;
                    if (n >= 9) last->fault_patch_id = patch_id;
                    if (n >= 10 && isfinite(strike_deg)) last->stamp_strike_deg = normalize_deg_gui(strike_deg);
                    if (n >= 11 && isfinite(depth_km)) last->stamp_depth_km = clampf_gui(depth_km, 0.0f, 700.0f);
                    if (n >= 12 && isfinite(width_km)) last->stamp_width_km = normalized_fault_width_km(width_km);
                    if (n >= 13 && isfinite(length_km)) last->stamp_length_km = normalized_stamp_length_km(length_km);
                }
            }
        } else {
            /* Backward-compatible source-only CSV rows. */
            int x = 0, y = 0, delay = 0;
            float h = 0.0f;
            if (parse_source_csv_line(p, &x, &y, &h, &delay)) {
                source_list_append(&new_sources, x, y, h, delay);
                source_set_last_slip_m(&new_sources, h > SOURCE_SLIP_HEIGHT_EPS_M ? h : 0.0f);
            }
        }
    }

    fclose(fp);

    source_list_clear(&g_sources);
    g_selected_source_i = -1;
    for (size_t i = 0; i < new_sources.count; ++i) {
        source_list_append(&g_sources,
                           new_sources.items[i].grid_x,
                           new_sources.items[i].grid_y,
                           new_sources.items[i].height_m,
                           new_sources.items[i].delay_s);
        source_set_last_slip_m(&g_sources, new_sources.items[i].slip_m);
        if (g_sources.count > 0) {
            SourceSpec *dst = &g_sources.items[g_sources.count - 1];
            dst->stamp_dip_deg = new_sources.items[i].stamp_dip_deg;
            dst->moment_area_factor = new_sources.items[i].moment_area_factor;
            if (dst->stamp_dip_deg < 5.0f || dst->stamp_dip_deg > 60.0f || !isfinite(dst->stamp_dip_deg)) {
                dst->stamp_dip_deg = source_stamp_dip_now_deg();
            }
            if (dst->moment_area_factor <= 0.01f || dst->moment_area_factor >= 100.0f || !isfinite(dst->moment_area_factor)) {
                dst->moment_area_factor = 1.0f / fault_dip_cos_deg(dst->stamp_dip_deg);
            }
            dst->fault_id = new_sources.items[i].fault_id;
            dst->fault_patch_id = new_sources.items[i].fault_patch_id;
            dst->stamp_strike_deg = new_sources.items[i].stamp_strike_deg;
            dst->stamp_depth_km = new_sources.items[i].stamp_depth_km;
            dst->stamp_width_km = new_sources.items[i].stamp_width_km;
            dst->stamp_length_km = new_sources.items[i].stamp_length_km;
        }
    }

    if (loaded_sources) *loaded_sources = new_sources.count;
    source_list_free(&new_sources);
    g_current_stamp_fault = -1;
    stamp_runtime_sources_max_fault_id();
    stamp_regenerate_auto_subsidence(NULL, 0, 0);
    reset_state_preserve_sources();
    return 1;
}

static void save_preset_dialog(HWND hwnd) {
    char file_name[MAX_PATH] = "tsunami_preset.csv";
    OPENFILENAMEA ofn;
    memset(&ofn, 0, sizeof(ofn));
    ofn.lStructSize = sizeof(ofn);
    ofn.hwndOwner = hwnd;
    ofn.lpstrFilter = "Preset CSV\0*.csv\0All files\0*.*\0";
    ofn.lpstrFile = file_name;
    ofn.nMaxFile = MAX_PATH;
    ofn.Flags = OFN_OVERWRITEPROMPT | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR;
    ofn.lpstrTitle = "Save current preset";
    if (GetSaveFileNameA(&ofn)) {
        if (save_current_preset_file(file_name)) {
            set_status("Saved preset: %s", file_name);
        } else {
            set_status("Failed to save preset.");
        }
        settings_refresh();
        InvalidateRect(hwnd, NULL, FALSE);
    }
}

static void load_preset_dialog(HWND hwnd) {
    char file_name[MAX_PATH] = "";
    OPENFILENAMEA ofn;
    memset(&ofn, 0, sizeof(ofn));
    ofn.lStructSize = sizeof(ofn);
    ofn.hwndOwner = hwnd;
    ofn.lpstrFilter = "Preset/Source CSV\0*.csv\0All files\0*.*\0";
    ofn.lpstrFile = file_name;
    ofn.nMaxFile = MAX_PATH;
    ofn.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR;
    ofn.lpstrTitle = "Load preset and apply now";
    if (GetOpenFileNameA(&ofn)) {
        size_t loaded = 0;
        source_undo_push("load preset");
        if (load_preset_file(file_name, &loaded)) {
            set_status("Loaded preset and applied %zu source point(s).", loaded);
        } else {
            set_status("Failed to load preset.");
        }
        settings_refresh();
        InvalidateRect(hwnd, NULL, FALSE);
    }
}

static void save_station_zone_csv_dialog(HWND hwnd) {
    save_station_csv_dialog(hwnd);
}

static void show_forecast_zone66_dialog(HWND hwnd) {
    /*
       This button used to show a popup table.  Now it toggles the map marker
       display to NHK-style forecast-zone maxima.
    */
    g_coast_marker_use_zone_max = !g_coast_marker_use_zone_max;
    if (g_coast_marker_use_zone_max) {
        g_coast_marker_use_peak = 1;
        set_status("Zone max markers ON: every station in a forecast zone shows that zone's peak height.");
    } else {
        set_status("Zone max markers OFF: station markers show %s values.",
                   g_coast_marker_use_peak ? "each station peak" : "live");
    }
    settings_refresh();
    InvalidateRect(hwnd, NULL, FALSE);
}

static void save_station_csv_dialog(HWND hwnd) {
    char file_name[MAX_PATH] = "tsunami_station_points.csv";
    OPENFILENAMEA ofn;
    memset(&ofn, 0, sizeof(ofn));
    ofn.lStructSize = sizeof(ofn);
    ofn.hwndOwner = hwnd;
    ofn.lpstrFilter = "CSV files\0*.csv\0All files\0*.*\0";
    ofn.lpstrFile = file_name;
    ofn.nMaxFile = MAX_PATH;
    ofn.Flags = OFN_OVERWRITEPROMPT | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR;
    ofn.lpstrTitle = "Save station + forecast-zone CSV";
    if (GetSaveFileNameA(&ofn)) {
        char zone_file[MAX_PATH];
        make_suffixed_path(file_name, "_forecast_zones", zone_file, sizeof(zone_file));
        write_station_file(file_name, &g_grid, &g_sim);
        write_forecast_zone_file(zone_file, &g_grid, &g_sim);
        set_status("Saved station CSV and forecast-zone CSV: %s", zone_file);
        InvalidateRect(hwnd, NULL, FALSE);
    }
}


static void save_override_csv_dialog(HWND hwnd) {
    char file_name[MAX_PATH] = "coast_override.csv";
    OPENFILENAMEA ofn;
    memset(&ofn, 0, sizeof(ofn));
    ofn.lStructSize = sizeof(ofn);
    ofn.hwndOwner = hwnd;
    ofn.lpstrFilter = "Coast override CSV\0*.csv\0All files\0*.*\0";
    ofn.lpstrFile = file_name;
    ofn.nMaxFile = MAX_PATH;
    ofn.Flags = OFN_OVERWRITEPROMPT | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR;
    ofn.lpstrTitle = "Save coastline correction CSV";
    if (GetSaveFileNameA(&ofn)) {
        if (save_land_override_csv(file_name, &g_grid)) {
            set_status("Saved coastline correction CSV: %s", file_name);
        } else {
            set_status("Failed to save coastline correction CSV.");
        }
        InvalidateRect(hwnd, NULL, FALSE);
    }
}

static void load_override_csv_dialog(HWND hwnd) {
    char file_name[MAX_PATH] = "coast_override.csv";
    OPENFILENAMEA ofn;
    memset(&ofn, 0, sizeof(ofn));
    ofn.lStructSize = sizeof(ofn);
    ofn.hwndOwner = hwnd;
    ofn.lpstrFilter = "Coast override CSV\0*.csv\0All files\0*.*\0";
    ofn.lpstrFile = file_name;
    ofn.nMaxFile = MAX_PATH;
    ofn.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR;
    ofn.lpstrTitle = "Load coastline correction CSV";
    if (GetOpenFileNameA(&ofn)) {
        if (load_land_override_csv(&g_grid, file_name)) {
            rebuild_solid_mask(&g_grid, g_coast_guard_cells);
            reset_simulation(&g_grid, &g_sim);
            apply_sources_to_simulation(&g_grid, &g_sim, &g_sources);
            g_time_s = 0;
            time_series_clear_points();
            time_series_record_current();
            set_status("Loaded coastline correction CSV. Overrides=%zu. Simulation reset.", count_land_overrides(&g_grid));
        } else {
            set_status("Failed to load coastline correction CSV.");
        }
        InvalidateRect(hwnd, NULL, FALSE);
    }
}


typedef struct PanelButtonDef {
    int id;
    const char *label;
} PanelButtonDef;

static const PanelButtonDef g_panel_button_defs[] = {
    { ID_BTN_RUN_PAUSE, "Run / Pause" },
    { ID_BTN_STEP, "Step" },
    { ID_BTN_RESET, "Reset" },
    { ID_BTN_CLEAR, "Clear sources" },
    { ID_BTN_LOAD_SOURCE, "Load sources" },
    { ID_BTN_SAVE_RESULT, "Save CSV" },
    { ID_BTN_SAVE_GRAPH, "Save graph" },
    { ID_BTN_SETTINGS, "Settings" },
    { ID_BTN_KEY_GUIDE, "Key guide" }
};

static void refresh_main_window(void) {
    if (g_main_hwnd) InvalidateRect(g_main_hwnd, NULL, FALSE);
}

static void layout_panel_buttons(HWND hwnd) {
    RECT rc;
    GetClientRect(hwnd, &rc);

    /*
       Arrange all 9 panel buttons as one anchored 3x3 block.
       The last button in g_panel_button_defs is Key guide, so row-major 3x3
       puts it at row 2, col 2: the bottom-right cell of the whole block.

       This is more stable than pinning only one button, because the whole
       control group moves together and stays clear of the upper status text.
    */
    int cols = 3;
    int rows = 3;
    int margin = 12;
    int gap = PANEL_BUTTON_GAP;
    int bh = PANEL_BUTTON_H;

    int block_w = PANEL_W - 24;
    int bw = (block_w - gap * (cols - 1)) / cols;
    if (bw < 70) bw = 70;

    int block_h = rows * bh + (rows - 1) * gap;
    int left = rc.right - margin - block_w;
    int top = rc.bottom - margin - block_h;

    /*
       Avoid overlapping the upper text area on very short windows.
       On normal 760px+ windows this still places the block near the bottom.
    */
    if (top < PANEL_BUTTON_TOP) top = PANEL_BUTTON_TOP;

    int count = (int)(sizeof(g_panel_button_defs) / sizeof(g_panel_button_defs[0]));
    for (int i = 0; i < count; ++i) {
        if (!g_panel_buttons[i]) continue;

        int col = i % cols;
        int row = i / cols;
        int x = left + col * (bw + gap);
        int y = top + row * (bh + gap);

        MoveWindow(g_panel_buttons[i], x, y, bw, bh, TRUE);
    }
}

static void create_panel_buttons(HWND hwnd) {
    int count = (int)(sizeof(g_panel_button_defs) / sizeof(g_panel_button_defs[0]));
    HINSTANCE inst = (HINSTANCE)GetWindowLongPtr(hwnd, GWLP_HINSTANCE);
    for (int i = 0; i < count; ++i) {
        g_panel_buttons[i] = CreateWindowA(
            "BUTTON", g_panel_button_defs[i].label,
            WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON,
            0, 0, 10, 10,
            hwnd, (HMENU)(INT_PTR)g_panel_button_defs[i].id, inst, NULL);
        if (g_text_font && g_panel_buttons[i]) SendMessage(g_panel_buttons[i], WM_SETFONT, (WPARAM)g_text_font, TRUE);
    }
    layout_panel_buttons(hwnd);
}

static void set_child_textf(HWND hwnd, int id, const char *fmt, ...) {
    char buf[256];
    va_list ap;
    va_start(ap, fmt);
    vsnprintf(buf, sizeof(buf), fmt, ap);
    va_end(ap);
    SetDlgItemTextA(hwnd, id, buf);
}

static void set_edit_textf_if_not_focused(HWND hwnd, int id, const char *fmt, ...) {
    HWND edit = GetDlgItem(hwnd, id);
    if (!edit) return;
    if (GetFocus() == edit) return;
    char buf[128];
    va_list ap;
    va_start(ap, fmt);
    vsnprintf(buf, sizeof(buf), fmt, ap);
    va_end(ap);
    SetWindowTextA(edit, buf);
}

static int read_edit_float(HWND hwnd, int id, float lo, float hi, float *out) {
    char buf[128];
    char *end = NULL;
    GetDlgItemTextA(hwnd, id, buf, sizeof(buf));
    float value = strtof(buf, &end);
    while (end && (*end == ' ' || *end == '\t' || *end == '\r' || *end == '\n')) end++;
    if (end == buf || (end && *end != '\0')) return 0;
    if (value < lo || value > hi) return 0;
    *out = value;
    return 1;
}

static int read_edit_int(HWND hwnd, int id, int lo, int hi, int *out) {
    char buf[128];
    char *end = NULL;
    GetDlgItemTextA(hwnd, id, buf, sizeof(buf));
    long value = strtol(buf, &end, 10);
    while (end && (*end == ' ' || *end == '\t' || *end == '\r' || *end == '\n')) end++;
    if (end == buf || (end && *end != '\0')) return 0;
    if (value < lo || value > hi) return 0;
    *out = (int)value;
    return 1;
}

static int round_delay_to_step(int delay_s) {
    if (delay_s < 0) delay_s = 0;
    int dt = g_dt_s > 0 ? g_dt_s : DEFAULT_DT_SECONDS;
    int rounded = ((delay_s + dt / 2) / dt) * dt;
    if (rounded < 0) rounded = 0;
    return rounded;
}

static HWND make_child(HWND parent, const char *klass, const char *text, DWORD style, int x, int y, int w, int h, int id) {
    HINSTANCE inst = (HINSTANCE)GetWindowLongPtr(parent, GWLP_HINSTANCE);
    HWND child = CreateWindowA(klass, text, WS_CHILD | WS_VISIBLE | style, x, y, w, h, parent, (HMENU)(INT_PTR)id, inst, NULL);
    if (g_text_font && child) SendMessage(child, WM_SETFONT, (WPARAM)g_text_font, TRUE);
    return child;
}


static void export_zone_assignments_dialog(HWND hwnd) {
    char file_name[MAX_PATH] = "zone_assignments.csv";
    OPENFILENAMEA ofn;
    memset(&ofn, 0, sizeof(ofn));
    ofn.lStructSize = sizeof(ofn);
    ofn.hwndOwner = hwnd;
    ofn.lpstrFilter = "Zone assignment CSV\0*.csv\0All files\0*.*\0";
    ofn.lpstrFile = file_name;
    ofn.nMaxFile = MAX_PATH;
    ofn.Flags = OFN_OVERWRITEPROMPT | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR;
    ofn.lpstrTitle = "Export temporary zone assignments";
    if (!GetSaveFileNameA(&ofn)) return;

    FILE *fp = fopen(file_name, "wb");
    if (!fp) {
        set_status("Failed to export zone assignments.");
        return;
    }

    fputs("\xEF\xBB\xBF", fp);
    fprintf(fp, "station_no,grid_index,grid_x,grid_y,zone_id,zone_name\n");

    init_station_zone_assignments();
    int max_i = (int)g_grid.station_count;
    if (max_i > MAX_STATION_ZONE_ASSIGNMENTS) max_i = MAX_STATION_ZONE_ASSIGNMENTS;

    int rows = 0;
    for (int i = 0; i < max_i; ++i) {
        if (g_station_zone_override_id[i] <= 0) continue;
        int idx = g_grid.station_index ? g_grid.station_index[i] : -1;
        int gx = (idx >= 0) ? (idx % (int)g_grid.width) : -1;
        int gy = (idx >= 0) ? (idx / (int)g_grid.width) : -1;
        fprintf(fp, "%d,%d,%d,%d,%d,", i + 1, idx, gx, gy, g_station_zone_override_id[i]);
        csv_write_escaped_utf8_from_acp(fp, g_station_zone_override_name[i]);
        fprintf(fp, "\n");
        rows++;
    }

    fclose(fp);
    set_status("Exported %d zone assignment row(s): %s", rows, file_name);
    settings_refresh();
    InvalidateRect(hwnd, NULL, FALSE);
}

static void create_zone_assign_window_class(HINSTANCE inst) {
    static int registered = 0;
    if (registered) return;

    WNDCLASSA wc;
    memset(&wc, 0, sizeof(wc));
    wc.lpfnWndProc = DefWindowProcA; /* replaced by SetWindowLongPtr after creation fallback */
    wc.hInstance = inst;
    wc.lpszClassName = "TsunamiZoneAssignWindow";
    wc.hCursor = LoadCursor(NULL, IDC_ARROW);
    wc.hbrBackground = (HBRUSH)(COLOR_BTNFACE + 1);
    RegisterClassA(&wc);
    registered = 1;
}

static LRESULT CALLBACK zone_assign_wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam);

static void open_zone_assign_window(HWND owner, int station_i) {
    if (g_shift_zone_assign_count <= 0) {
        if (station_i < 0 || station_i >= (int)g_grid.station_count) return;
        shift_zone_assign_clear_batch();
        shift_zone_assign_add_station(station_i);
    }
    if (g_shift_zone_assign_count <= 0) return;
    station_i = g_shift_zone_assign_station_list[0];

    if (g_zone_assign_hwnd) {
        DestroyWindow(g_zone_assign_hwnd);
        g_zone_assign_hwnd = NULL;
    }

    HINSTANCE inst = (HINSTANCE)GetWindowLongPtr(owner, GWLP_HINSTANCE);

    static int class_registered = 0;
    if (!class_registered) {
        WNDCLASSA wc;
        memset(&wc, 0, sizeof(wc));
        wc.lpfnWndProc = zone_assign_wnd_proc;
        wc.hInstance = inst;
        wc.lpszClassName = "TsunamiZoneAssignWindow";
        wc.hCursor = LoadCursor(NULL, IDC_ARROW);
        wc.hbrBackground = (HBRUSH)(COLOR_BTNFACE + 1);
        RegisterClassA(&wc);
        class_registered = 1;
    }

    g_zone_assign_station_i = station_i;
    g_zone_assign_hwnd = CreateWindowExA(WS_EX_TOOLWINDOW,
                                         "TsunamiZoneAssignWindow",
                                         "Assign forecast zone",
                                         WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU,
                                         CW_USEDEFAULT, CW_USEDEFAULT, 420, 210,
                                         owner, NULL, inst, NULL);
    if (g_zone_assign_hwnd) {
        ShowWindow(g_zone_assign_hwnd, SW_SHOWNORMAL);
        SetForegroundWindow(g_zone_assign_hwnd);
    }
}

static LRESULT CALLBACK zone_assign_wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam) {
    (void)lparam;

    switch (msg) {
    case WM_CREATE: {
        int station_i = g_zone_assign_station_i;
        int zone_id = station_zone_id_for_display(station_i);
        const char *name = station_zone_name_for_display(station_i);

        char buf[256];
        snprintf(buf, sizeof(buf), "Target: %d station(s), first Obs #%d", g_shift_zone_assign_count, station_i + 1);
        make_child(hwnd, "STATIC", buf, 0, 16, 14, 380, 20, -1);

        int gx = -1, gy = -1, sxg = -1, syg = -1;
        get_station_info(station_i, &gx, &gy, &sxg, &syg, NULL, NULL, NULL, NULL, NULL);
        snprintf(buf, sizeof(buf), "first grid %d,%d  sample %d,%d", gx, gy, sxg, syg);
        make_child(hwnd, "STATIC", buf, 0, 16, 38, 380, 20, -1);

        make_child(hwnd, "STATIC", "Zone id", 0, 16, 70, 80, 20, -1);
        HWND edit_id = make_child(hwnd, "EDIT", "", WS_BORDER | ES_AUTOHSCROLL, 100, 66, 80, 24, ID_ZONE_ASSIGN_EDIT_ID);
        snprintf(buf, sizeof(buf), "%d", zone_id > 0 ? zone_id : 67);
        SetWindowTextA(edit_id, buf);

        make_child(hwnd, "STATIC", "Zone name", 0, 16, 104, 80, 20, -1);
        HWND edit_name = make_child(hwnd, "EDIT", "", WS_BORDER | ES_AUTOHSCROLL, 100, 100, 280, 24, ID_ZONE_ASSIGN_EDIT_NAME);
        SetWindowTextA(edit_name, name && name[0] ? name : "custom_zone");

        make_child(hwnd, "BUTTON", "Apply", BS_PUSHBUTTON, 100, 142, 110, 28, ID_ZONE_ASSIGN_APPLY);
        make_child(hwnd, "BUTTON", "Cancel", BS_PUSHBUTTON, 225, 142, 110, 28, ID_ZONE_ASSIGN_CANCEL);
        return 0;
    }

    case WM_COMMAND:
        switch (LOWORD(wparam)) {
        case ID_ZONE_ASSIGN_APPLY: {
            char id_text[64];
            char name_text[ZONE_ASSIGN_NAME_LEN];
            GetDlgItemTextA(hwnd, ID_ZONE_ASSIGN_EDIT_ID, id_text, sizeof(id_text));
            GetDlgItemTextA(hwnd, ID_ZONE_ASSIGN_EDIT_NAME, name_text, sizeof(name_text));
            int zone_id = atoi(id_text);
            if (zone_id <= 0) zone_id = 67;
            apply_station_zone_assignment_batch(zone_id, name_text);
            g_shift_zone_assign_active = 0;
            shift_zone_assign_clear_batch();
            settings_refresh();
            InvalidateRect(GetParent(hwnd), NULL, FALSE);
            DestroyWindow(hwnd);
            return 0;
        }
        case ID_ZONE_ASSIGN_CANCEL:
            g_shift_zone_assign_active = 0;
            shift_zone_assign_clear_batch();
            DestroyWindow(hwnd);
            return 0;
        }
        break;

    case WM_CLOSE:
        DestroyWindow(hwnd);
        return 0;

    case WM_DESTROY:
        if (hwnd == g_zone_assign_hwnd) g_zone_assign_hwnd = NULL;
        return 0;
    }

    return DefWindowProc(hwnd, msg, wparam, lparam);
}

static void settings_create_tab_buttons(HWND hwnd, int active_tab) {
    int x = 14;
    int y = 12;
    make_child(hwnd, "BUTTON", active_tab == 0 ? "[Main]" : "Main", BS_PUSHBUTTON, x, y, 110, 28, ID_SET_TAB_MAIN);
    make_child(hwnd, "BUTTON", active_tab == 1 ? "[Colors]" : "Colors", BS_PUSHBUTTON, x + 116, y, 110, 28, ID_SET_TAB_COLORS);
    make_child(hwnd, "BUTTON", active_tab == 2 ? "[Preset]" : "Preset", BS_PUSHBUTTON, x + 232, y, 110, 28, ID_SET_TAB_PRESET);
}

static BOOL CALLBACK destroy_settings_child_proc(HWND child, LPARAM lparam) {
    (void)lparam;
    DestroyWindow(child);
    return TRUE;
}

static void settings_destroy_children(HWND hwnd) {
    EnumChildWindows(hwnd, destroy_settings_child_proc, 0);
}

static void settings_set_color_edit_texts(HWND hwnd) {
    char buf[4096];

    HWND cell_edit = GetDlgItem(hwnd, ID_SET_EDIT_CELL_COLORS);
    if (cell_edit) {
        if (g_cell_color_custom_enabled && g_cell_color_key_count > 0) {
            format_color_keyframes_text(g_cell_color_keys, g_cell_color_key_count,
                                        default_cell_color_keyframes_text(), buf, sizeof(buf));
            SetWindowTextA(cell_edit, buf);
        } else {
            SetWindowTextA(cell_edit, default_cell_color_keyframes_text());
        }
    }

    HWND coast_edit = GetDlgItem(hwnd, ID_SET_EDIT_COAST_COLORS);
    if (coast_edit) {
        if (g_coast_color_custom_enabled && g_coast_color_key_count > 0) {
            format_color_keyframes_text(g_coast_color_keys, g_coast_color_key_count,
                                        default_coast_color_keyframes_text(), buf, sizeof(buf));
            SetWindowTextA(coast_edit, buf);
        } else {
            SetWindowTextA(coast_edit, default_coast_color_keyframes_text());
        }
    }
}

static void settings_create_main_page(HWND hwnd) {
    int x = 14, y = 12;
    int label_w = 160;
    int edit_w = 85;
    int row_h = 32;

    settings_create_tab_buttons(hwnd, 0);
    y += 42;

    make_child(hwnd, "STATIC", "Simulation settings", 0, x, y, 480, 20, -1); y += 28;
    make_child(hwnd, "STATIC", "Type values directly, then press Apply numeric values.", 0, x, y, 520, 20, -1); y += 24;
    make_child(hwnd, "STATIC", "Delay is rounded to the current time step so sources trigger correctly.", 0, x, y, 520, 20, -1); y += 30;

    make_child(hwnd, "STATIC", "Slip (m)", 0, x, y+4, label_w, 20, -1);
    make_child(hwnd, "EDIT", "", WS_BORDER | ES_AUTOHSCROLL, x+label_w, y, edit_w, 24, ID_SET_EDIT_UPLIFT);
    make_child(hwnd, "STATIC", "Delay cursor", 0, x+280, y+4, 100, 20, -1);
    make_child(hwnd, "STATIC", "auto by rupture", 0, x+350, y+4, 120, 20, -1); y += row_h;

    make_child(hwnd, "STATIC", "Fault width (km)", 0, x, y+4, label_w, 20, -1);
    make_child(hwnd, "EDIT", "", WS_BORDER | ES_AUTOHSCROLL, x+label_w, y, edit_w, 24, ID_SET_EDIT_WIDTH);
    make_child(hwnd, "STATIC", "Speed", 0, x+280, y+4, 100, 20, -1);
    make_child(hwnd, "EDIT", "", WS_BORDER | ES_AUTOHSCROLL, x+390, y, edit_w, 24, ID_SET_EDIT_SPEED); y += row_h;

    make_child(hwnd, "STATIC", "Rupture v (km/s)", 0, x, y+4, label_w, 20, -1);
    make_child(hwnd, "EDIT", "", WS_BORDER | ES_AUTOHSCROLL, x+label_w, y, edit_w, 24, ID_SET_EDIT_RUPTURE_SPEED);
    make_child(hwnd, "STATIC", "Smooth (0.0-5.0)", 0, x+280, y+4, 120, 20, -1);
    make_child(hwnd, "EDIT", "", WS_BORDER | ES_AUTOHSCROLL, x+410, y, edit_w, 24, ID_SET_EDIT_STRIKE_SMOOTH); y += row_h;

    make_child(hwnd, "STATIC", "Source depth (km)", 0, x, y+4, label_w, 20, -1);
    make_child(hwnd, "EDIT", "", WS_BORDER | ES_AUTOHSCROLL, x+label_w, y, edit_w, 24, ID_SET_EDIT_SOURCE_DEPTH);
    make_child(hwnd, "STATIC", "Delay base (s)", 0, x+280, y+4, 100, 20, -1);
    make_child(hwnd, "EDIT", "", WS_BORDER | ES_AUTOHSCROLL, x+390, y, edit_w, 24, ID_SET_EDIT_DELAY); y += row_h;

    make_child(hwnd, "STATIC", "Subsidence (%)", 0, x, y+4, label_w, 20, -1);
    make_child(hwnd, "EDIT", "", WS_BORDER | ES_AUTOHSCROLL, x+label_w, y, edit_w, 24, ID_SET_EDIT_RATIO); y += row_h;

    make_child(hwnd, "STATIC", "Dip angle (5-60 deg)", 0, x, y+4, label_w, 20, -1);
    make_child(hwnd, "EDIT", "", WS_BORDER | ES_AUTOHSCROLL, x+label_w, y, edit_w, 24, ID_SET_EDIT_DIP);
    make_child(hwnd, "STATIC", "Strike-slip (%)", 0, x+280, y+4, 110, 20, -1);
    make_child(hwnd, "EDIT", "", WS_BORDER | ES_AUTOHSCROLL, x+410, y, edit_w, 24, ID_SET_EDIT_STRIKE_SLIP); y += row_h;

    make_child(hwnd, "STATIC", "Wave opacity (%)", 0, x, y+4, label_w, 20, -1);
    make_child(hwnd, "EDIT", "", WS_BORDER | ES_AUTOHSCROLL, x+label_w, y, edit_w, 24, ID_SET_EDIT_WAVE_ALPHA);
    make_child(hwnd, "STATIC", "Coast loss (%)", 0, x+280, y+4, 100, 20, -1);
    make_child(hwnd, "EDIT", "", WS_BORDER | ES_AUTOHSCROLL, x+390, y, edit_w, 24, ID_SET_EDIT_COAST_LOSS); y += row_h;

    make_child(hwnd, "STATIC", "JESIM start delay (s)", 0, x, y+4, label_w, 20, -1);
    make_child(hwnd, "EDIT", "", WS_BORDER | ES_AUTOHSCROLL, x+label_w, y, edit_w, 24, ID_SET_EDIT_JESIM_SYNC_DELAY);
    make_child(hwnd, "STATIC", "real sync offset", 0, x+280, y+4, 160, 20, -1); y += row_h;

    make_child(hwnd, "BUTTON", "Apply numeric values", BS_PUSHBUTTON, x+260, y-2, 175, 28, ID_SET_APPLY_NUMERIC); y += 42;

    make_child(hwnd, "STATIC", "", 0, x, y, 640, 22, ID_SET_LBL_SOURCE); y += 26;
    make_child(hwnd, "STATIC", "", 0, x, y, 640, 22, ID_SET_LBL_FAULT); y += 26;
    make_child(hwnd, "STATIC", "", 0, x, y, 640, 22, ID_SET_LBL_COAST); y += 26;
    make_child(hwnd, "STATIC", "", 0, x, y, 640, 22, ID_SET_LBL_DISPLAY); y += 26;
    make_child(hwnd, "STATIC", "", 0, x, y, 640, 22, ID_SET_LBL_SIM); y += 58;

    make_child(hwnd, "STATIC", "Mode / display toggles", 0, x, y, 640, 20, -1); y += 32;
    make_child(hwnd, "BUTTON", "Flat / reverse", BS_PUSHBUTTON, x, y, 120, 26, ID_SET_PROFILE_TOGGLE);
    make_child(hwnd, "BUTTON", "Flip down side", BS_PUSHBUTTON, x+126, y, 120, 26, ID_SET_SUBSIDE_TOGGLE);
    make_child(hwnd, "BUTTON", "Slip R/L", BS_PUSHBUTTON, x+252, y, 90, 26, ID_SET_STRIKE_SLIP_TOGGLE);
    make_child(hwnd, "BUTTON", "Peak / live", BS_PUSHBUTTON, x+348, y, 100, 26, ID_SET_COAST_MODE); y += 44;

    make_child(hwnd, "BUTTON", "Coast absorb", BS_PUSHBUTTON, x, y, 120, 26, ID_SET_COAST_TOGGLE);
    make_child(hwnd, "BUTTON", "Bilateral", BS_PUSHBUTTON, x+126, y, 100, 26, ID_SET_BILATERAL_TOGGLE);
    make_child(hwnd, "BUTTON", "Reset delay", BS_PUSHBUTTON, x+232, y, 110, 26, ID_SET_RUPTURE_DELAY_RESET); y += 44;

    make_child(hwnd, "BUTTON", "Source markers", BS_PUSHBUTTON, x, y, 120, 26, ID_SET_SOURCE_MARKERS);
    make_child(hwnd, "BUTTON", "Coast markers", BS_PUSHBUTTON, x+126, y, 120, 26, ID_SET_COAST_MARKERS);
    make_child(hwnd, "BUTTON", "Station rings", BS_PUSHBUTTON, x+252, y, 120, 26, ID_SET_RINGS); y += 44;

    make_child(hwnd, "BUTTON", "Flip X", BS_PUSHBUTTON, x, y, 90, 26, ID_SET_FLIP_X);
    make_child(hwnd, "BUTTON", "Flip Y", BS_PUSHBUTTON, x+96, y, 90, 26, ID_SET_FLIP_Y);
    make_child(hwnd, "BUTTON", "Coast guard", BS_PUSHBUTTON, x+192, y, 110, 26, ID_SET_COAST_GUARD);
    make_child(hwnd, "BUTTON", "Low flicker", BS_PUSHBUTTON, x+308, y, 110, 26, ID_SET_DOUBLE_BUFFER); y += 54;

    make_child(hwnd, "BUTTON", "Close", BS_PUSHBUTTON, x+520, y, 110, 28, ID_SET_CLOSE);
}

static void settings_create_colors_page(HWND hwnd) {
    int x = 14, y = 12;

    settings_create_tab_buttons(hwnd, 1);
    y += 42;

    make_child(hwnd, "STATIC", "Color settings", 0, x, y, 480, 22, -1); y += 24;
    make_child(hwnd, "STATIC", "Format: height(m),#RRGGBB  one setting per line. Coast markers also support Step.", 0, x, y, 650, 20, -1); y += 26;

    make_child(hwnd, "STATIC", "Ocean cell / wave overlay colors", 0, x, y, 640, 20, -1); y += 22;
    make_child(hwnd, "EDIT", "",
               WS_BORDER | ES_MULTILINE | ES_AUTOVSCROLL | WS_VSCROLL,
               x, y, 640, 205, ID_SET_EDIT_CELL_COLORS); y += 214;
    make_child(hwnd, "BUTTON", g_cell_color_smooth ? "Cell curve: Smooth" : "Cell curve: Linear",
               BS_PUSHBUTTON, x, y, 170, 28, ID_SET_CELL_CURVE_TOGGLE);
    make_child(hwnd, "STATIC", "Linear = bent settings line, Smooth = eased curve", 0, x+185, y+5, 420, 20, -1); y += 42;

    make_child(hwnd, "STATIC", "Coastal observation marker colors", 0, x, y, 640, 20, -1); y += 22;
    make_child(hwnd, "EDIT", "",
               WS_BORDER | ES_MULTILINE | ES_AUTOVSCROLL | WS_VSCROLL,
               x, y, 640, 205, ID_SET_EDIT_COAST_COLORS); y += 214;
    {
        char coast_btn[64];
        snprintf(coast_btn, sizeof(coast_btn), "Coast mode: %s", coast_color_mode_name());
        make_child(hwnd, "BUTTON", coast_btn,
                   BS_PUSHBUTTON, x, y, 180, 28, ID_SET_COAST_CURVE_TOGGLE);
    }
    make_child(hwnd, "STATIC", "Linear/Smooth = interpolated, Step = threshold colors", 0, x+190, y+5, 430, 20, -1); y += 40;

    make_child(hwnd, "STATIC", "", 0, x, y, 640, 22, ID_SET_LBL_COLOR_STATUS); y += 36;
    make_child(hwnd, "BUTTON", "Apply + save colors", BS_PUSHBUTTON, x, y, 160, 30, ID_SET_APPLY_COLORS);
    make_child(hwnd, "BUTTON", "Restore built-in colors", BS_PUSHBUTTON, x+170, y, 200, 30, ID_SET_RESET_COLORS);
    make_child(hwnd, "BUTTON", "Close", BS_PUSHBUTTON, x+520, y, 110, 30, ID_SET_CLOSE);

    settings_set_color_edit_texts(hwnd);
}

static void settings_create_preset_page(HWND hwnd) {
    int x = 14, y = 12;

    settings_create_tab_buttons(hwnd, 2);
    y += 42;

    make_child(hwnd, "STATIC", "Preset / result tools", 0, x, y, 520, 22, -1); y += 26;
    make_child(hwnd, "STATIC", "Save current source list + main source/fault settings. Load applies immediately.", 0, x, y, 660, 20, -1); y += 36;

    make_child(hwnd, "BUTTON", "Save current preset", BS_PUSHBUTTON, x, y, 180, 30, ID_SET_SAVE_PRESET);
    make_child(hwnd, "BUTTON", "Load preset now", BS_PUSHBUTTON, x + 194, y, 170, 30, ID_SET_LOAD_PRESET); y += 50;

    make_child(hwnd, "STATIC", "Forecast-zone result view", 0, x, y, 520, 22, -1); y += 26;
    make_child(hwnd, "STATIC", "Zone max markers unify all station colors in each zone to that zone's peak. CSV still exports station + zone values.", 0, x, y, 660, 20, -1); y += 34;

    make_child(hwnd, "BUTTON", g_coast_marker_use_zone_max ? "Zone max markers: ON" : "Zone max markers: OFF", BS_PUSHBUTTON, x, y, 180, 30, ID_SET_SHOW_ZONE66);
    make_child(hwnd, "BUTTON", "Save station + zone CSV", BS_PUSHBUTTON, x + 194, y, 230, 30, ID_SET_SAVE_STATION_ZONE_CSV); y += 42;
    make_child(hwnd, "BUTTON", "Export zone assignment CSV", BS_PUSHBUTTON, x, y, 240, 30, ID_SET_EXPORT_ZONE_ASSIGNMENTS); y += 40;

    make_child(hwnd, "STATIC", "", 0, x, y, 660, 22, ID_SET_LBL_PRESET_STATUS); y += 34;

    make_child(hwnd, "STATIC", "Next step: virtual overseas zones 67..100, with Japan-only / overseas-only / all export choice.", 0, x, y, 660, 22, -1); y += 44;
    make_child(hwnd, "BUTTON", "Close", BS_PUSHBUTTON, x + 520, y, 110, 30, ID_SET_CLOSE);
}

static void settings_switch_tab(HWND hwnd, int tab) {
    settings_destroy_children(hwnd);
    if (tab < 0) tab = 0;
    if (tab > 2) tab = 2;
    g_settings_current_tab = tab;
    if (g_settings_current_tab == 1) settings_create_colors_page(hwnd);
    else if (g_settings_current_tab == 2) settings_create_preset_page(hwnd);
    else settings_create_main_page(hwnd);
    settings_refresh();
}

static void settings_refresh(void) {
    if (!g_settings_hwnd) return;
    set_child_textf(g_settings_hwnd, ID_SET_LBL_SOURCE,
                    "Source: input slip %.2fm | Mw %.2f | M0 %.2e fault-area | peak %.2fm | %s",
                    current_source_input_slip_m(), current_moment_magnitude(), current_seismic_moment_nm(),
                    current_equivalent_peak_surface_m(), source_profile_name());
    set_child_textf(g_settings_hwnd, ID_SET_LBL_FAULT,
                    "Fault: width %.1f km | plane area %.0f km2 | shallow %.0fkm T%.2f SS%.2f | strike %.0f | smooth %.1f | dip %.0f",
                    current_fault_width_km(), current_source_fault_area_km2(), g_source_depth_km, current_tsunami_depth_factor(),
                    current_strike_slip_depth_factor(), g_fault_strike_deg, g_strike_smoothing_strength, g_fault_dip_deg);
    set_child_textf(g_settings_hwnd, ID_SET_LBL_COAST,
                    "Coast: absorption %s | energy loss %.0f%% | markers %s/%s",
                    coastal_reflection_absorption_enabled() ? "on" : "off",
                    coastal_reflection_absorption_energy_loss() * 100.0f,
                    g_show_coast_height_markers ? "on" : "off",
                    g_coast_marker_use_peak ? "peak" : "live");
    set_child_textf(g_settings_hwnd, ID_SET_LBL_DISPLAY,
                    "Display: source markers %s | rings %s | flip X=%s Y=%s | guard %d | coeff %.2fx | alpha %.0f%%",
                    g_show_source_markers ? "on" : "off",
                    g_show_time_series_markers ? "shown" : "hidden",
                    g_display_flip_x ? "on" : "off",
                    g_display_flip_y ? "on" : "off",
                    g_coast_guard_cells,
                    g_wave_display_coeff,
                    g_wave_overlay_alpha * 100.0f);
    set_child_textf(g_settings_hwnd, ID_SET_LBL_SIM,
                    "Simulation: %s | %.2fh | speed %d step/frame | %zu source(s) | JESIM sync +%ds",
                    g_running ? "running" : "paused", g_time_s / 3600.0, g_steps_per_frame, g_sources.count,
                    g_jesim_calc_start_delay_s);

    set_edit_textf_if_not_focused(g_settings_hwnd, ID_SET_EDIT_UPLIFT, "%.2f", g_new_source_height_m);
    set_edit_textf_if_not_focused(g_settings_hwnd, ID_SET_EDIT_DELAY, "%d", g_new_source_delay_s);
    set_edit_textf_if_not_focused(g_settings_hwnd, ID_SET_EDIT_WIDTH, "%.1f", current_fault_width_km());
    set_edit_textf_if_not_focused(g_settings_hwnd, ID_SET_EDIT_RATIO, "%.0f", g_subsidence_ratio * 100.0f);
    set_edit_textf_if_not_focused(g_settings_hwnd, ID_SET_EDIT_DIP, "%.0f", g_fault_dip_deg);
    set_edit_textf_if_not_focused(g_settings_hwnd, ID_SET_EDIT_STRIKE_SLIP, "%.0f", g_strike_slip_mix * 100.0f);
    set_edit_textf_if_not_focused(g_settings_hwnd, ID_SET_EDIT_WAVE_ALPHA, "%.0f", g_wave_overlay_alpha * 100.0f);
    set_edit_textf_if_not_focused(g_settings_hwnd, ID_SET_EDIT_RUPTURE_SPEED, "%.2f", g_rupture_velocity_km_s);
    set_edit_textf_if_not_focused(g_settings_hwnd, ID_SET_EDIT_STRIKE_SMOOTH, "%.1f", g_strike_smoothing_strength);
    set_edit_textf_if_not_focused(g_settings_hwnd, ID_SET_EDIT_SOURCE_DEPTH, "%.1f", g_source_depth_km);
    set_edit_textf_if_not_focused(g_settings_hwnd, ID_SET_EDIT_SPEED, "%d", g_steps_per_frame);
    set_edit_textf_if_not_focused(g_settings_hwnd, ID_SET_EDIT_COAST_LOSS, "%.0f", coastal_reflection_absorption_energy_loss() * 100.0f);
    set_edit_textf_if_not_focused(g_settings_hwnd, ID_SET_EDIT_JESIM_SYNC_DELAY, "%d", g_jesim_calc_start_delay_s);

    set_child_textf(g_settings_hwnd, ID_SET_LBL_COLOR_STATUS,
                    "Custom colors: cell %s (%s), coast %s (%s) | auto-save: tsunami_color_settings.ini",
                    g_cell_color_custom_enabled ? "on" : "built-in",
                    g_cell_color_smooth ? "smooth" : "linear",
                    g_coast_color_custom_enabled ? "on" : "built-in",
                    coast_color_mode_name());

    set_child_textf(g_settings_hwnd, ID_SET_LBL_PRESET_STATUS,
                    "Preset state: %zu source point(s), Mw %.2f, zone max %.2f m, markers %s, zone edits %d",
                    g_sources.count, current_moment_magnitude(), current_forecast_zone_max_height(),
                    g_coast_marker_use_zone_max ? "zone-max" : "station",
                    custom_zone_assignment_count());
}

static void settings_changed(const char *message) {
    if (message) snprintf(g_status, sizeof(g_status), "%s", message);
    settings_refresh();
    refresh_main_window();
}

static void adjust_wave_display_coeff(float factor) {
    if (factor <= 0.0f) return;
    g_wave_display_coeff *= factor;
    if (g_wave_display_coeff < 0.05f) g_wave_display_coeff = 0.05f;
    if (g_wave_display_coeff > 20.0f) g_wave_display_coeff = 20.0f;
    set_status("Wave display coefficient = %.2fx. Color settings are unchanged.", g_wave_display_coeff);
}

static void open_key_guide(HWND owner) {
    const char *guide =
        "Main mouse controls\n"
        "- Left click/drag: draw source; drag direction sets smoothed fault strike\n"
        "- Hover station marker: exact station popup\n\n"
        "Panel buttons\n"
        "- Run/Pause, Step, Reset, Clear sources\n"
        "- Load sources, Save CSV, Save graph\n"
        "- Settings opens numeric inputs and toggles\n\n"
        "Remaining keyboard shortcuts\n"
        "Space: run/pause\n"
        "N: one step\n"
        "R: reset, C: clear sources\n"
        "T: select hovered station\n"
        "Shift+T: add hovered station\n"
        "Y: save selected station graph\n"
        "O: save max-wave PNG\n"
        "S: finite-fault stamp mode on/off, Ctrl+S: save station CSV\n"
        "Stamp mode: click stamp; N/Enter complete fault + auto subsidence; G regen subsidence; Tab cycle; Z/X strike; [/ ] delay; J/K length; < > width+length\n"
        "B: selected-station rings\n"
        "Ctrl+D, then B: bathymetry debug map\n"
        "Ctrl+D, then L: land/sea display invert\n"
        "5 / 6: wave display coefficient down/up\n"
        "7 / 8: wave opacity down/up\n"
        "W: live / max / max+arrival layer\n"
        "I: toggle strike-slip right/left sense\n"
        "Ctrl+D, then R: reset rupture delay cursor\n"
        "Ctrl+wheel: zoom, Ctrl+left drag: pan view, markers scale too\n"
        "E: advanced source-node tune mode on/off\n"
        "Node tune: Ctrl-click toggle, Shift-click add, A all, C/Esc clear, Tab primary/next\n"
        "Node tune: +/- or PgUp/PgDn slip; uplift auto-calculated, [/ ] delay, arrows move, F, U, Del\n"
        "Shift hover stations, release: assign id/name to all touched\n"
        "Settings: Main/Colors/Preset tabs, dip, rupture, color settings\n"
        "Esc: quit";
    MessageBoxA(owner, guide, "Key guide", MB_OK | MB_ICONINFORMATION);
}

static void apply_numeric_settings_from_edits(HWND hwnd) {
    float uplift_m = 0.0f;
    int delay_s = 0;
    float width_km = 0.0f;
    float ratio_percent = 0.0f;
    float dip_deg = 0.0f;
    float strike_slip_percent = 0.0f;
    float wave_alpha_percent = 0.0f;
    float rupture_speed = 0.0f;
    float strike_smooth = 0.0f;
    float source_depth = 0.0f;
    int speed = 0;
    float coast_loss_percent = 0.0f;
    int jesim_sync_delay_s = 0;

    if (!read_edit_float(hwnd, ID_SET_EDIT_UPLIFT, 0.01f, 100.0f, &uplift_m)) {
        MessageBoxA(hwnd, "Slip must be a number from 0.01 to 100 m.", "Invalid input", MB_OK | MB_ICONWARNING);
        return;
    }
    if (!read_edit_int(hwnd, ID_SET_EDIT_DELAY, 0, 86400, &delay_s)) {
        MessageBoxA(hwnd, "Delay must be an integer from 0 to 86400 seconds.", "Invalid input", MB_OK | MB_ICONWARNING);
        return;
    }
    if (!read_edit_float(hwnd, ID_SET_EDIT_WIDTH, FAULT_WIDTH_MIN_KM, FAULT_WIDTH_MAX_KM, &width_km)) {
        MessageBoxA(hwnd, "Fault width must be a number from 1 to 800 km. This is the true down-dip fault-plane width, not grid cells.", "Invalid input", MB_OK | MB_ICONWARNING);
        return;
    }
    if (!read_edit_float(hwnd, ID_SET_EDIT_RATIO, 0.0f, 90.0f, &ratio_percent)) {
        MessageBoxA(hwnd, "Subsidence ratio must be from 0 to 90 percent.", "Invalid input", MB_OK | MB_ICONWARNING);
        return;
    }
    if (!read_edit_float(hwnd, ID_SET_EDIT_DIP, 5.0f, 60.0f, &dip_deg)) {
        MessageBoxA(hwnd, "Fault dip must be from 5 to 60 degrees. Use about 10-30 for subduction megathrust tests.", "Invalid input", MB_OK | MB_ICONWARNING);
        return;
    }
    if (!read_edit_float(hwnd, ID_SET_EDIT_STRIKE_SLIP, 0.0f, 100.0f, &strike_slip_percent)) {
        MessageBoxA(hwnd, "Strike-slip mix must be from 0 to 100 percent.", "Invalid input", MB_OK | MB_ICONWARNING);
        return;
    }
    if (!read_edit_float(hwnd, ID_SET_EDIT_WAVE_ALPHA, 0.0f, 100.0f, &wave_alpha_percent)) {
        MessageBoxA(hwnd, "Wave opacity must be from 0 to 100 percent.", "Invalid input", MB_OK | MB_ICONWARNING);
        return;
    }
    if (!read_edit_float(hwnd, ID_SET_EDIT_RUPTURE_SPEED, 0.10f, 8.00f, &rupture_speed)) {
        MessageBoxA(hwnd, "Rupture velocity must be from 0.10 to 8.00 km/s.", "Invalid input", MB_OK | MB_ICONWARNING);
        return;
    }
    if (!read_edit_float(hwnd, ID_SET_EDIT_STRIKE_SMOOTH, 0.0f, 5.0f, &strike_smooth)) {
        MessageBoxA(hwnd, "Mouse strike smoothing must be from 0.0 to 5.0.", "Invalid input", MB_OK | MB_ICONWARNING);
        return;
    }
    if (!read_edit_float(hwnd, ID_SET_EDIT_SOURCE_DEPTH, 0.0f, 700.0f, &source_depth)) {
        MessageBoxA(hwnd, "Source depth must be from 0 to 700 km.", "Invalid input", MB_OK | MB_ICONWARNING);
        return;
    }
    if (!read_edit_int(hwnd, ID_SET_EDIT_SPEED, 1, 100, &speed)) {
        MessageBoxA(hwnd, "Speed must be an integer from 1 to 100 steps/frame.", "Invalid input", MB_OK | MB_ICONWARNING);
        return;
    }
    if (!read_edit_float(hwnd, ID_SET_EDIT_COAST_LOSS, 0.0f, 95.0f, &coast_loss_percent)) {
        MessageBoxA(hwnd, "Coast energy loss must be from 0 to 95 percent.", "Invalid input", MB_OK | MB_ICONWARNING);
        return;
    }
    if (!read_edit_int(hwnd, ID_SET_EDIT_JESIM_SYNC_DELAY, 0, 604800, &jesim_sync_delay_s)) {
        MessageBoxA(hwnd, "JESIM start delay must be an integer from 0 to 604800 seconds.", "Invalid input", MB_OK | MB_ICONWARNING);
        return;
    }

    int rounded_delay = round_delay_to_step(delay_s);
    g_new_source_height_m = uplift_m;
    g_new_source_delay_s = rounded_delay;
    if (g_fault_stamp_mode) {
        float old_width = current_fault_width_km();
        float ratio = stamp_safe_aspect_ratio(g_fault_stamp_length_km, old_width);
        set_fault_width_km_value(width_km);
        g_fault_stamp_length_km = normalized_stamp_length_km(current_fault_width_km() * ratio);
    } else {
        set_fault_width_km_value(width_km);
    }
    g_fault_dip_deg = clampf_gui(dip_deg, 5.0f, 60.0f);
    g_subsidence_ratio = clampf_gui(ratio_percent / 100.0f, 0.0f, 0.90f);
    g_strike_slip_mix = clampf_gui(strike_slip_percent / 100.0f, 0.0f, 1.0f);
    g_wave_overlay_alpha = clampf_gui(wave_alpha_percent / 100.0f, 0.0f, 1.0f);
    g_rupture_velocity_km_s = clampf_gui(rupture_speed, 0.10f, 8.00f);
    g_strike_smoothing_strength = clampf_gui(strike_smooth, 0.0f, 5.0f);
    g_source_depth_km = clampf_gui(source_depth, 0.0f, 700.0f);
    g_steps_per_frame = clampi_gui(speed, 1, 100);
    g_jesim_calc_start_delay_s = clampi_gui(jesim_sync_delay_s, 0, 604800);
    set_coastal_reflection_absorption(coastal_reflection_absorption_enabled(), coast_loss_percent / 100.0f);

    if (rounded_delay != delay_s) {
        settings_changed("Applied numeric settings. Delay was rounded to the current time step.");
    } else {
        settings_changed("Applied numeric settings.");
    }
}

static void handle_settings_command(HWND hwnd, int id) {
    char msg[192];
    switch (id) {
    case ID_SET_TAB_MAIN:
        settings_switch_tab(hwnd, 0);
        break;
    case ID_SET_TAB_COLORS:
        settings_switch_tab(hwnd, 1);
        break;
    case ID_SET_TAB_PRESET:
        settings_switch_tab(hwnd, 2);
        break;
    case ID_SET_SAVE_PRESET:
        save_preset_dialog(hwnd);
        break;
    case ID_SET_LOAD_PRESET:
        load_preset_dialog(hwnd);
        break;
    case ID_SET_SHOW_ZONE66:
        show_forecast_zone66_dialog(hwnd);
        break;
    case ID_SET_SAVE_STATION_ZONE_CSV:
        save_station_zone_csv_dialog(hwnd);
        break;
    case ID_SET_EXPORT_ZONE_ASSIGNMENTS:
        export_zone_assignments_dialog(hwnd);
        break;
    case ID_SET_APPLY_COLORS:
        apply_color_keyframes_from_edits(hwnd);
        break;
    case ID_SET_RESET_COLORS:
        reset_color_keyframes_to_builtin(hwnd);
        break;
    case ID_SET_CELL_CURVE_TOGGLE:
        g_cell_color_smooth = !g_cell_color_smooth;
        SetDlgItemTextA(hwnd, ID_SET_CELL_CURVE_TOGGLE, g_cell_color_smooth ? "Cell curve: Smooth" : "Cell curve: Linear");
        if (auto_save_color_settings()) {
            settings_changed(g_cell_color_smooth ? "Cell color interpolation = smooth. Saved for next launch." : "Cell color interpolation = linear. Saved for next launch.");
        } else {
            settings_changed(g_cell_color_smooth ? "Cell color interpolation = smooth." : "Cell color interpolation = linear.");
        }
        break;
    case ID_SET_COAST_CURVE_TOGGLE:
        if (!g_coast_color_smooth && !g_coast_color_step) {
            g_coast_color_smooth = 1;
            g_coast_color_step = 0;
        } else if (g_coast_color_smooth) {
            g_coast_color_smooth = 0;
            g_coast_color_step = 1;
        } else {
            g_coast_color_smooth = 0;
            g_coast_color_step = 0;
        }
        {
            char btn[64];
            snprintf(btn, sizeof(btn), "Coast mode: %s", coast_color_mode_name());
            SetDlgItemTextA(hwnd, ID_SET_COAST_CURVE_TOGGLE, btn);
        }
        if (auto_save_color_settings()) {
            settings_changed(g_coast_color_step ? "Coast marker color mode = step. Saved for next launch." :
                             (g_coast_color_smooth ? "Coast marker color interpolation = smooth. Saved for next launch." :
                                                      "Coast marker color interpolation = linear. Saved for next launch."));
        } else {
            settings_changed(g_coast_color_step ? "Coast marker color mode = step." :
                             (g_coast_color_smooth ? "Coast marker color interpolation = smooth." :
                                                      "Coast marker color interpolation = linear."));
        }
        break;
    case ID_SET_APPLY_NUMERIC:
        apply_numeric_settings_from_edits(hwnd);
        break;
    case ID_SET_UPLIFT_DOWN:
        g_new_source_height_m -= 0.10f;
        if (g_new_source_height_m < 0.01f) g_new_source_height_m = 0.01f;
        snprintf(msg, sizeof(msg), "New source slip = %.2f m.", g_new_source_height_m);
        settings_changed(msg);
        break;
    case ID_SET_UPLIFT_UP:
        g_new_source_height_m += 0.10f;
        snprintf(msg, sizeof(msg), "New source slip = %.2f m.", g_new_source_height_m);
        settings_changed(msg);
        break;
    case ID_SET_UPLIFT_FINE_DOWN:
        g_new_source_height_m -= 0.01f;
        if (g_new_source_height_m < 0.01f) g_new_source_height_m = 0.01f;
        snprintf(msg, sizeof(msg), "New source slip = %.2f m.", g_new_source_height_m);
        settings_changed(msg);
        break;
    case ID_SET_UPLIFT_FINE_UP:
        g_new_source_height_m += 0.01f;
        snprintf(msg, sizeof(msg), "New source slip = %.2f m.", g_new_source_height_m);
        settings_changed(msg);
        break;
    case ID_SET_DELAY_DOWN:
        g_new_source_delay_s -= g_dt_s;
        if (g_new_source_delay_s < g_dt_s) g_new_source_delay_s = g_dt_s;
        snprintf(msg, sizeof(msg), "New source delay = %d s.", g_new_source_delay_s);
        settings_changed(msg);
        break;
    case ID_SET_DELAY_UP:
        g_new_source_delay_s += g_dt_s;
        snprintf(msg, sizeof(msg), "New source delay = %d s.", g_new_source_delay_s);
        settings_changed(msg);
        break;
    case ID_SET_BRUSH_DOWN:
        if (g_fault_stamp_mode) {
            stamp_adjust_width_keep_aspect(-5.0f);
            snprintf(msg, sizeof(msg), "Stamp size = %.1f km width x %.1f km length.", current_fault_width_km(), g_fault_stamp_length_km);
        } else {
            set_fault_width_km_value(current_fault_width_km() - 5.0f);
            snprintf(msg, sizeof(msg), "Fault width = %.1f km (projected radius about %d cells).", current_fault_width_km(), reverse_profile_radius_cells());
        }
        settings_changed(msg);
        break;
    case ID_SET_BRUSH_UP:
        if (g_fault_stamp_mode) {
            stamp_adjust_width_keep_aspect(5.0f);
            snprintf(msg, sizeof(msg), "Stamp size = %.1f km width x %.1f km length.", current_fault_width_km(), g_fault_stamp_length_km);
        } else {
            set_fault_width_km_value(current_fault_width_km() + 5.0f);
            snprintf(msg, sizeof(msg), "Fault width = %.1f km (projected radius about %d cells).", current_fault_width_km(), reverse_profile_radius_cells());
        }
        settings_changed(msg);
        break;
    case ID_SET_PROFILE_TOGGLE:
        g_source_profile_mode = !g_source_profile_mode;
        snprintf(msg, sizeof(msg), "Source profile = %s.", source_profile_name());
        settings_changed(msg);
        break;
    case ID_SET_STRIKE_DOWN:
        g_fault_strike_deg = normalize_deg_gui(g_fault_strike_deg - 5.0f);
        snprintf(msg, sizeof(msg), "Strike angle = %.0f deg.", g_fault_strike_deg);
        settings_changed(msg);
        break;
    case ID_SET_STRIKE_UP:
        g_fault_strike_deg = normalize_deg_gui(g_fault_strike_deg + 5.0f);
        snprintf(msg, sizeof(msg), "Strike angle = %.0f deg.", g_fault_strike_deg);
        settings_changed(msg);
        break;
    case ID_SET_STRIKE_FINE_DOWN:
        g_fault_strike_deg = normalize_deg_gui(g_fault_strike_deg - 1.0f);
        snprintf(msg, sizeof(msg), "Strike angle = %.0f deg.", g_fault_strike_deg);
        settings_changed(msg);
        break;
    case ID_SET_STRIKE_FINE_UP:
        g_fault_strike_deg = normalize_deg_gui(g_fault_strike_deg + 1.0f);
        snprintf(msg, sizeof(msg), "Strike angle = %.0f deg.", g_fault_strike_deg);
        settings_changed(msg);
        break;
    case ID_SET_SUBSIDE_TOGGLE:
        g_subsidence_side = -g_subsidence_side;
        snprintf(msg, sizeof(msg), "Reverse-fault subsidence side = %s.", subsidence_side_name());
        settings_changed(msg);
        break;
    case ID_SET_STRIKE_SLIP_TOGGLE:
        g_strike_slip_sense = -g_strike_slip_sense;
        snprintf(msg, sizeof(msg), "Strike-slip sense = %s.", strike_slip_sense_name());
        settings_changed(msg);
        break;
    case ID_SET_RUPTURE_DELAY_RESET:
        g_rupture_delay_cursor_s = 0.0f;
        settings_changed("Rupture delay cursor reset to 0 s.");
        break;
    case ID_SET_BILATERAL_TOGGLE:
        g_bilateral_rupture = !g_bilateral_rupture;
        snprintf(msg, sizeof(msg), "Rupture delay mode = %s.", g_bilateral_rupture ? "bilateral" : "unilateral");
        settings_changed(msg);
        break;
    case ID_SET_RATIO_DOWN:
        g_subsidence_ratio = clampf_gui(g_subsidence_ratio - 0.05f, 0.10f, 0.90f);
        snprintf(msg, sizeof(msg), "Subsidence ratio = %.0f%%.", g_subsidence_ratio * 100.0f);
        settings_changed(msg);
        break;
    case ID_SET_RATIO_UP:
        g_subsidence_ratio = clampf_gui(g_subsidence_ratio + 0.05f, 0.10f, 0.90f);
        snprintf(msg, sizeof(msg), "Subsidence ratio = %.0f%%.", g_subsidence_ratio * 100.0f);
        settings_changed(msg);
        break;
    case ID_SET_SPEED_DOWN:
        g_steps_per_frame = clampi_gui(g_steps_per_frame - 1, 1, 100);
        snprintf(msg, sizeof(msg), "Speed = %d step/frame.", g_steps_per_frame);
        settings_changed(msg);
        break;
    case ID_SET_SPEED_UP:
        g_steps_per_frame = clampi_gui(g_steps_per_frame + 1, 1, 100);
        snprintf(msg, sizeof(msg), "Speed = %d step/frame.", g_steps_per_frame);
        settings_changed(msg);
        break;
    case ID_SET_COAST_TOGGLE:
        set_coastal_reflection_absorption(!coastal_reflection_absorption_enabled(), coastal_reflection_absorption_energy_loss());
        snprintf(msg, sizeof(msg), "Coast boundary absorption: %s.", coastal_reflection_absorption_enabled() ? "on" : "off");
        settings_changed(msg);
        break;
    case ID_SET_COAST_LOSS_DOWN: {
        float loss = clampf_gui(coastal_reflection_absorption_energy_loss() - 0.05f, 0.0f, 0.95f);
        set_coastal_reflection_absorption(coastal_reflection_absorption_enabled(), loss);
        snprintf(msg, sizeof(msg), "Coast boundary energy loss = %.0f%%.", loss * 100.0f);
        settings_changed(msg);
        break;
    }
    case ID_SET_COAST_LOSS_UP: {
        float loss = clampf_gui(coastal_reflection_absorption_energy_loss() + 0.05f, 0.0f, 0.95f);
        set_coastal_reflection_absorption(coastal_reflection_absorption_enabled(), loss);
        snprintf(msg, sizeof(msg), "Coast boundary energy loss = %.0f%%.", loss * 100.0f);
        settings_changed(msg);
        break;
    }
    case ID_SET_SOURCE_MARKERS:
        g_show_source_markers = !g_show_source_markers;
        snprintf(msg, sizeof(msg), "Source markers: %s.", g_show_source_markers ? "shown" : "hidden");
        settings_changed(msg);
        break;
    case ID_SET_COAST_MARKERS:
        g_show_coast_height_markers = !g_show_coast_height_markers;
        snprintf(msg, sizeof(msg), "Coast markers: %s.", g_show_coast_height_markers ? "shown" : "hidden");
        settings_changed(msg);
        break;
    case ID_SET_COAST_MODE:
        g_coast_marker_use_zone_max = 0;
        g_coast_marker_use_peak = !g_coast_marker_use_peak;
        snprintf(msg, sizeof(msg), "Coast marker mode: %s.", g_coast_marker_use_peak ? "peak" : "live");
        settings_changed(msg);
        break;
    case ID_SET_RINGS:
        g_show_time_series_markers = !g_show_time_series_markers;
        snprintf(msg, sizeof(msg), "Selected station rings: %s.", g_show_time_series_markers ? "shown" : "hidden");
        settings_changed(msg);
        break;
    case ID_SET_FLIP_X:
        g_display_flip_x = !g_display_flip_x;
        invalidate_base_render_cache();
        snprintf(msg, sizeof(msg), "Horizontal display flip: %s.", g_display_flip_x ? "on" : "off");
        settings_changed(msg);
        break;
    case ID_SET_FLIP_Y:
        g_display_flip_y = !g_display_flip_y;
        invalidate_base_render_cache();
        snprintf(msg, sizeof(msg), "Vertical display flip: %s.", g_display_flip_y ? "on" : "off");
        settings_changed(msg);
        break;
    case ID_SET_COAST_GUARD:
        g_coast_guard_cells = (g_coast_guard_cells == 0) ? 1 : 0;
        rebuild_mask_and_reset();
        settings_changed("Coast guard toggled; simulation reset.");
        break;
    case ID_SET_DOUBLE_BUFFER:
        g_double_buffer_enabled = !g_double_buffer_enabled;
        snprintf(msg, sizeof(msg), "Low-flicker double buffering: %s.", g_double_buffer_enabled ? "on" : "off");
        settings_changed(msg);
        break;
    case ID_SET_CLOSE:
        DestroyWindow(hwnd);
        break;
    }
}

static LRESULT CALLBACK settings_wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam) {
    (void)lparam;
    switch (msg) {
    case WM_CREATE:
        g_settings_current_tab = 0;
        settings_create_main_page(hwnd);
        settings_refresh();
        return 0;
    case WM_COMMAND:
        handle_settings_command(hwnd, LOWORD(wparam));
        return 0;
    case WM_CLOSE:
        DestroyWindow(hwnd);
        return 0;
    case WM_DESTROY:
        g_settings_hwnd = NULL;
        return 0;
    }
    return DefWindowProc(hwnd, msg, wparam, lparam);
}

static void open_settings_window(HWND owner) {
    if (g_settings_hwnd) {
        ShowWindow(g_settings_hwnd, SW_SHOWNORMAL);
        SetForegroundWindow(g_settings_hwnd);
        settings_refresh();
        return;
    }
    static int class_registered = 0;
    HINSTANCE inst = (HINSTANCE)GetWindowLongPtr(owner, GWLP_HINSTANCE);
    if (!class_registered) {
        WNDCLASSA wc;
        memset(&wc, 0, sizeof(wc));
        wc.lpfnWndProc = settings_wnd_proc;
        wc.hInstance = inst;
        wc.lpszClassName = "TsunamiSettingsWindow";
        wc.hCursor = LoadCursor(NULL, IDC_ARROW);
        wc.hbrBackground = (HBRUSH)(COLOR_BTNFACE + 1);
        RegisterClassA(&wc);
        class_registered = 1;
    }
    g_settings_hwnd = CreateWindowExA(WS_EX_TOOLWINDOW, "TsunamiSettingsWindow", "Tsunami Settings",
                                      WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU,
                                      CW_USEDEFAULT, CW_USEDEFAULT, 740, 930,
                                      owner, NULL, inst, NULL);
    if (g_settings_hwnd) {
        ShowWindow(g_settings_hwnd, SW_SHOWNORMAL);
        UpdateWindow(g_settings_hwnd);
        settings_refresh();
    }
}

static void handle_panel_command(HWND hwnd, int id) {
    switch (id) {
    case ID_BTN_RUN_PAUSE:
        g_running = !g_running;
        if (g_running) {
            g_show_time_series_markers = 0;
            jesim_start_realtime_warning_clock();
            set_status("Running. JESIM observation sync fixed wall-clock 1x active (+%ds). Selected station rings hidden.", g_jesim_calc_start_delay_s);
        } else {
            set_status("Paused.");
        }
        settings_refresh();
        break;
    case ID_BTN_STEP:
        advance_one_step();
        set_status("Advanced one step.");
        settings_refresh();
        break;
    case ID_BTN_RESET:
        reset_state_preserve_sources();
        settings_refresh();
        break;
    case ID_BTN_CLEAR:
        clear_sources_and_reset_rupture_delay();
        reset_state_preserve_sources();
        set_status("Cleared all source points and reset rupture delay cursor.");
        settings_refresh();
        break;
    case ID_BTN_LOAD_SOURCE:
        load_source_csv_dialog(hwnd);
        settings_refresh();
        return;
    case ID_BTN_SAVE_RESULT:
        save_station_csv_dialog(hwnd);
        return;
    case ID_BTN_SAVE_GRAPH:
        save_time_series_dialog(hwnd);
        return;
    case ID_BTN_SETTINGS:
        open_settings_window(hwnd);
        return;
    case ID_BTN_KEY_GUIDE:
        open_key_guide(hwnd);
        return;
    }
    InvalidateRect(hwnd, NULL, FALSE);
}

static LRESULT CALLBACK wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam) {
    switch (msg) {
    case WM_CREATE:
        g_main_hwnd = hwnd;
        create_panel_buttons(hwnd);
        SetTimer(hwnd, TIMER_ID, TIMER_MS, NULL);
        return 0;

    case WM_TIMER:
        if (wparam == TIMER_ID) {
            jesim_realtime_update_tick();
            if (g_running) {
                for (int i = 0; i < g_steps_per_frame; ++i) advance_one_step();

                if (g_jesim_transmit_mode) {
                    g_transmit_repaint_counter++;
                    if (g_transmit_repaint_counter >= g_transmit_repaint_interval_ticks) {
                        g_transmit_repaint_counter = 0;
                        InvalidateRect(hwnd, NULL, FALSE);
                    }
                } else {
                    InvalidateRect(hwnd, NULL, FALSE);
                }
            }
        }
        return 0;

    case WM_SIZE:
        compute_viewport(hwnd);
        layout_panel_buttons(hwnd);
        // Do not request background erase; WM_ERASEBKGND is also suppressed below.
        InvalidateRect(hwnd, NULL, FALSE);
        return 0;

    case WM_COMMAND:
        handle_panel_command(hwnd, LOWORD(wparam));
        return 0;

    case WM_ERASEBKGND:
        // Prevent GDI from clearing the window before WM_PAINT.
        // Without this, animation flickers because the background appears for a moment.
        return 1;

    case WM_MOUSEMOVE: {
        int mx = GET_X_LPARAM(lparam);
        int my = GET_Y_LPARAM(lparam);
        int gx, gy;
        g_mouse_screen_x = mx;
        g_mouse_screen_y = my;
        if (g_node_tune_mode && g_node_tune_box_select_active && (wparam & MK_LBUTTON)) {
            node_tune_box_select_update(mx, my);
            InvalidateRect(hwnd, NULL, FALSE);
            return 0;
        }
        if (screen_to_grid(mx, my, &gx, &gy)) {
            g_mouse_grid_x = gx;
            g_mouse_grid_y = gy;
            if (!g_node_tune_mode && !g_fault_stamp_mode && (!g_jesim_transmit_mode || (GetKeyState(VK_SHIFT) & 0x8000))) {
                g_hover_station_i = find_hover_station_at_screen(mx, my);
            } else {
                g_hover_station_i = -1;
            }
            if ((GetKeyState(VK_SHIFT) & 0x8000) && g_hover_station_i >= 0) {
                int added = shift_zone_assign_add_station(g_hover_station_i);
                set_status("%s Shift zone batch: %d station(s). Last Obs #%d, id %d, %s. Release Shift to assign all.",
                           added ? "Added to" : "Hovering",
                           g_shift_zone_assign_count,
                           g_hover_station_i + 1,
                           station_zone_id_for_display(g_hover_station_i),
                           station_zone_name_for_display(g_hover_station_i));
            }
            if (g_pan_dragging && (wparam & MK_LBUTTON) && (GetKeyState(VK_CONTROL) & 0x8000)) {
                int dx = mx - g_pan_last_mouse_x;
                int dy = my - g_pan_last_mouse_y;
                g_pan_last_mouse_x = mx;
                g_pan_last_mouse_y = my;
                g_view_pan_x += dx;
                g_view_pan_y += dy;
                compute_viewport(hwnd);
                set_status("Panning view. Zoom %.2fx.", g_view_zoom);
            } else if (g_drag_painting && (wparam & MK_LBUTTON)) {
                if (g_last_paint_grid_x < 0 || g_last_paint_grid_y < 0) {
                    g_active_paint_delay_override_s = rounded_source_delay_with_cursor(g_rupture_delay_cursor_s);
                    g_drag_affected_cells += paint_source_disc(hwnd, gx, gy);
                    g_active_paint_delay_override_s = -1;
                } else {
                    g_drag_affected_cells += paint_source_line(hwnd, g_last_paint_grid_x, g_last_paint_grid_y, gx, gy);
                }
                g_last_paint_grid_x = gx;
                g_last_paint_grid_y = gy;
                set_status("Drawing sources... affected %d cell(s). auto strike %.0f deg, smooth %.1f, delay cursor %.1fs.",
                           g_drag_affected_cells, g_fault_strike_deg, g_strike_smoothing_strength, g_rupture_delay_cursor_s);
            }
        } else {
            g_mouse_grid_x = -1;
            g_mouse_grid_y = -1;
            g_mouse_screen_x = -1;
            g_mouse_screen_y = -1;
            g_hover_station_i = -1;
        }
        InvalidateRect(hwnd, NULL, FALSE);
        return 0;
    }

    case WM_LBUTTONDOWN: {
        int mx = GET_X_LPARAM(lparam);
        int my = GET_Y_LPARAM(lparam);
        int gx, gy;

        if (g_node_tune_mode) {
            g_mouse_screen_x = mx;
            g_mouse_screen_y = my;
            if (screen_to_grid(mx, my, &gx, &gy)) {
                g_mouse_grid_x = gx;
                g_mouse_grid_y = gy;
                if (GetKeyState(VK_SHIFT) & 0x8000) {
                    SetCapture(hwnd);
                    node_tune_box_select_begin(mx, my);
                } else {
                    node_tune_select_source_at_screen(mx, my);
                }
            } else {
                g_mouse_grid_x = -1;
                g_mouse_grid_y = -1;
                g_selected_source_i = -1;
                set_status("Node tune: click inside the map on a source marker, or Shift-drag a rectangle over source markers.");
            }
            InvalidateRect(hwnd, NULL, FALSE);
            return 0;
        }

        if (g_fault_stamp_mode) {
            g_mouse_screen_x = mx;
            g_mouse_screen_y = my;
            if (screen_to_grid(mx, my, &gx, &gy)) {
                g_mouse_grid_x = gx;
                g_mouse_grid_y = gy;
                stamp_add_fault_patch(hwnd, gx, gy);
                settings_refresh();
            } else {
                set_status("Stamp fault mode: click inside the map. S exits stamp mode; N/Enter completes current fault.");
            }
            InvalidateRect(hwnd, NULL, FALSE);
            return 0;
        }

        SetCapture(hwnd);
        g_drag_painting = 1;
        g_drag_affected_cells = 0;
        g_last_paint_grid_x = -1;
        g_last_paint_grid_y = -1;
        g_drag_start_grid_x = -1;
        g_drag_start_grid_y = -1;
        g_drag_source_start_count = g_sources.count;
        g_drag_start_delay_cursor_s = g_rupture_delay_cursor_s;
        reset_auto_strike_filter();
        g_mouse_screen_x = mx;
        g_mouse_screen_y = my;
        if ((GetKeyState(VK_SHIFT) & 0x8000)) {
            g_drag_painting = 0;
            if (screen_to_grid(mx, my, &gx, &gy)) {
                g_hover_station_i = find_hover_station_at_screen(mx, my);
                if (g_hover_station_i >= 0) {
                    shift_zone_assign_add_station(g_hover_station_i);
                    set_status("Shift zone batch: %d station(s). Release Shift to assign all.", g_shift_zone_assign_count);
                } else {
                    set_status("Shift zone assign mode: hover over observation markers. Source drawing disabled.");
                }
            }
            InvalidateRect(hwnd, NULL, FALSE);
        } else if ((GetKeyState(VK_CONTROL) & 0x8000)) {
            g_drag_painting = 0;
            g_pan_dragging = 1;
            g_pan_last_mouse_x = mx;
            g_pan_last_mouse_y = my;
            set_status("Panning view. Hold Ctrl and drag. Zoom %.2fx.", g_view_zoom);
            InvalidateRect(hwnd, NULL, FALSE);
        } else if (screen_to_grid(mx, my, &gx, &gy)) {
            g_mouse_grid_x = gx;
            g_mouse_grid_y = gy;
            if (!g_node_tune_mode && !g_jesim_transmit_mode) {
                g_hover_station_i = find_hover_station_at_screen(mx, my);
            } else {
                g_hover_station_i = -1;
            }
            g_drag_start_grid_x = gx;
            g_drag_start_grid_y = gy;
            g_active_paint_delay_override_s = rounded_source_delay_with_cursor(g_rupture_delay_cursor_s);
            source_undo_push("source brush stroke");
            g_drag_affected_cells += paint_source_disc(hwnd, gx, gy);
            g_active_paint_delay_override_s = -1;
            g_last_paint_grid_x = gx;
            g_last_paint_grid_y = gy;
            if (g_drag_affected_cells > 0) {
                set_status("Started %s source at x=%d y=%d slip=%.2f m, Mw=%.2f, depth=%.0f km, peak=%.2f m. Drag to set auto strike.",
                           source_profile_name(), gx, gy, current_source_input_slip_m(), current_moment_magnitude(),
                           g_source_depth_km, current_equivalent_peak_surface_m());
            } else {
                MessageBeep(MB_ICONWARNING);
                set_status("Source ignored: clicked land/coast-guard cell.");
            }
            InvalidateRect(hwnd, NULL, FALSE);
        }
        return 0;
    }

    case WM_LBUTTONUP:
        if (g_node_tune_mode && g_node_tune_box_select_active) {
            g_node_tune_box_cur_x = GET_X_LPARAM(lparam);
            g_node_tune_box_cur_y = GET_Y_LPARAM(lparam);
            node_tune_box_select_finish();
            ReleaseCapture();
            InvalidateRect(hwnd, NULL, FALSE);
            return 0;
        }
        if (g_pan_dragging) {
            g_pan_dragging = 0;
            ReleaseCapture();
            set_status("View pan finished. Zoom %.2fx.", g_view_zoom);
            InvalidateRect(hwnd, NULL, FALSE);
            return 0;
        }
        if (g_drag_painting) {
            int end_x = g_last_paint_grid_x;
            int end_y = g_last_paint_grid_y;
            if (g_bilateral_rupture && g_drag_start_grid_x >= 0 && end_x >= 0) {
                update_recent_source_delays_bilateral(g_drag_source_start_count,
                                                       g_drag_start_grid_x, g_drag_start_grid_y,
                                                       end_x, end_y,
                                                       g_drag_start_delay_cursor_s);
            }
            if (g_drag_start_grid_x >= 0 && end_x >= 0) {
                int ss_adjusted = repaint_strike_slip_long_segment(hwnd,
                                                                   g_drag_start_grid_x, g_drag_start_grid_y,
                                                                   end_x, end_y,
                                                                   g_drag_start_delay_cursor_s,
                                                                   g_drag_source_start_count);
                if (ss_adjusted > 0) g_drag_affected_cells += ss_adjusted;
            }
            g_drag_painting = 0;
            g_last_paint_grid_x = -1;
            g_last_paint_grid_y = -1;
            g_active_paint_delay_override_s = -1;
            ReleaseCapture();
            set_status("Finished source stroke. Affected %d cell(s). Auto strike %.0f deg. Delay cursor %.1fs. Total sources: %zu.",
                       g_drag_affected_cells, g_fault_strike_deg, g_rupture_delay_cursor_s, g_sources.count);
            InvalidateRect(hwnd, NULL, FALSE);
        }
        return 0;

    case WM_CAPTURECHANGED:
        g_drag_painting = 0;
        g_pan_dragging = 0;
        g_node_tune_box_select_active = 0;
        g_last_paint_grid_x = -1;
        g_last_paint_grid_y = -1;
        return 0;

    case WM_MOUSEWHEEL:
        if (GET_KEYSTATE_WPARAM(wparam) & MK_CONTROL) {
            POINT pt;
            pt.x = GET_X_LPARAM(lparam);
            pt.y = GET_Y_LPARAM(lparam);
            ScreenToClient(hwnd, &pt);

            int delta = GET_WHEEL_DELTA_WPARAM(wparam);
            double factor = delta > 0 ? 1.18 : (1.0 / 1.18);
            zoom_view_at_screen_point(hwnd, pt.x, pt.y, factor);
            set_status("View zoom %.2fx. Ctrl+drag to pan.", g_view_zoom);
            InvalidateRect(hwnd, NULL, FALSE);
            return 0;
        }
        break;

    case WM_KEYUP:
        if (wparam == VK_SHIFT) {
            if (g_shift_zone_assign_active && g_shift_zone_assign_count > 0) {
                open_zone_assign_window(hwnd, -1);
                return 0;
            }
            g_shift_zone_assign_active = 0;
            shift_zone_assign_clear_batch();
        }
        break;

    case WM_KEYDOWN:
        if ((GetKeyState(VK_CONTROL) & 0x8000) && wparam == 'Z') {
            source_undo_restore(hwnd);
            settings_refresh();
            InvalidateRect(hwnd, NULL, FALSE);
            return 0;
        }
        if (g_node_tune_mode) {
            if (node_tune_handle_key(hwnd, wparam)) {
                settings_refresh();
                InvalidateRect(hwnd, NULL, FALSE);
                return 0;
            }
        } else if (wparam == 'E') {
            g_node_tune_mode = 1;
            g_drag_painting = 0;
            g_pan_dragging = 0;
            node_tune_sync_selected_bounds();
            if (node_tune_selected_count() == 0 && g_sources.count > 0) node_tune_select_only(0);
            node_tune_show_selected_status("Advanced node tune mode ON");
            settings_refresh();
            InvalidateRect(hwnd, NULL, FALSE);
            return 0;
        } else if (wparam == 'S' && !(GetKeyState(VK_CONTROL) & 0x8000)) {
            g_fault_stamp_mode = !g_fault_stamp_mode;
            g_drag_painting = 0;
            g_pan_dragging = 0;
            set_status("Finite-fault stamp mode: %s. Click stamps patches; [/ ] delay; < > width+length; N/Enter completes current fault; Z/X strike; J/K length.",
                       g_fault_stamp_mode ? "ON" : "off");
            settings_refresh();
            InvalidateRect(hwnd, NULL, FALSE);
            return 0;
        } else if (g_fault_stamp_mode) {
            if (stamp_mode_handle_key(hwnd, wparam)) {
                settings_refresh();
                InvalidateRect(hwnd, NULL, FALSE);
                return 0;
            }
        }
        switch (wparam) {
        case VK_SPACE:
            g_running = !g_running;
            if (g_running) {
                g_show_time_series_markers = 0;
                jesim_start_realtime_warning_clock();
                set_status("Running. JESIM observation sync fixed wall-clock 1x active (+%ds).", g_jesim_calc_start_delay_s);
            } else {
                set_status("Paused.");
            }
            break;
        case 'N':
            advance_one_step();
            set_status("Advanced one step.");
            break;
        case 'R':
            if (GetKeyState(VK_CONTROL) & 0x8000) {
                g_jesim_transmit_mode = !g_jesim_transmit_mode;
                if (g_jesim_transmit_mode) {
                    g_show_coast_height_markers = 0;
                    g_show_time_series_markers = 0;
                    g_wave_overlay_alpha = 0.0f;
                }
                g_transmit_repaint_counter = 0;
                invalidate_base_render_cache();
                set_status("JESIM transmit-only render: %s.", g_jesim_transmit_mode ? "on" : "off");
                refresh_main_window();
                return 0;
            }
            reset_state_preserve_sources();
            break;
        case 'C':
            source_undo_push("clear all sources");
            clear_sources_and_reset_rupture_delay();
            reset_state_preserve_sources();
            set_status("Cleared all source points and reset rupture delay cursor.");
            break;
        case 'L':
            if (g_debug_key_armed) {
                g_debug_key_armed = 0;
                g_invert_landsea_display = !g_invert_landsea_display;
                invalidate_base_render_cache();
                set_status("Land/sea display inversion: %s.", g_invert_landsea_display ? "on" : "off");
                refresh_main_window();
                return 0;
            }
            load_source_csv_dialog(hwnd);
            return 0;
        case 'S':
            save_station_csv_dialog(hwnd);
            return 0;
        case 'Q':
            g_source_profile_mode = !g_source_profile_mode;
            set_status("Source profile = %s. Strike angle = %.0f deg.", source_profile_name(), g_fault_strike_deg);
            break;
        case 'D':
            if (GetKeyState(VK_CONTROL) & 0x8000) {
                g_debug_key_armed = 1;
                set_status("Debug key armed. Press B for bathymetry, L for land/sea inversion, or R for rupture-delay reset.");
            } else {
                g_subsidence_side = -g_subsidence_side;
                set_status("Reverse-fault subsidence side = %s.", subsidence_side_name());
            }
            break;
        case 'Z':
        case 'X':
            set_status("Manual strike control removed. Drag the mouse to set fault strike automatically.");
            break;
        case '9':
            g_subsidence_ratio = clampf_gui(g_subsidence_ratio - 0.05f, 0.10f, 0.90f);
            set_status("Subsidence ratio = %.0f%% of uplift.", g_subsidence_ratio * 100.0f);
            break;
        case '0':
            g_subsidence_ratio = clampf_gui(g_subsidence_ratio + 0.05f, 0.10f, 0.90f);
            set_status("Subsidence ratio = %.0f%% of uplift.", g_subsidence_ratio * 100.0f);
            break;
        case VK_UP:
            g_steps_per_frame = clampi_gui(g_steps_per_frame + 1, 1, 100);
            set_status("Speed set to %d step/frame.", g_steps_per_frame);
            break;
        case VK_DOWN:
            g_steps_per_frame = clampi_gui(g_steps_per_frame - 1, 1, 100);
            set_status("Speed set to %d step/frame.", g_steps_per_frame);
            break;
        case VK_OEM_PLUS:
        case '=': {
            float step = 0.10f;
            if (GetKeyState(VK_SHIFT) & 0x8000) step = 0.50f;
            if (GetKeyState(VK_CONTROL) & 0x8000) step = 0.01f;
            g_new_source_height_m += step;
            set_status("New source slip = %.2f m. Step = %.2f m.", g_new_source_height_m, step);
            break;
        }
        case VK_OEM_MINUS:
        case '-': {
            float step = 0.10f;
            if (GetKeyState(VK_SHIFT) & 0x8000) step = 0.50f;
            if (GetKeyState(VK_CONTROL) & 0x8000) step = 0.01f;
            g_new_source_height_m -= step;
            if (g_new_source_height_m < 0.01f) g_new_source_height_m = 0.01f;
            set_status("New source slip = %.2f m. Step = %.2f m.", g_new_source_height_m, step);
            break;
        }
        case VK_OEM_4:
            g_new_source_delay_s -= g_dt_s;
            if (g_new_source_delay_s < g_dt_s) g_new_source_delay_s = g_dt_s;
            set_status("New source delay = %d s.", g_new_source_delay_s);
            break;
        case VK_OEM_6:
            g_new_source_delay_s += g_dt_s;
            set_status("New source delay = %d s.", g_new_source_delay_s);
            break;
        case VK_OEM_COMMA: {
            float step = fault_width_key_step_km();
            set_fault_width_km_value(current_fault_width_km() - step);
            set_status("Fault width = %.1f km. Step = %.1f km. Projected radius about %d cells.",
                       current_fault_width_km(), step, reverse_profile_radius_cells());
            break;
        }
        case VK_OEM_PERIOD: {
            float step = fault_width_key_step_km();
            set_fault_width_km_value(current_fault_width_km() + step);
            set_status("Fault width = %.1f km. Step = %.1f km. Projected radius about %d cells.",
                       current_fault_width_km(), step, reverse_profile_radius_cells());
            break;
        }
        case 'V':
            g_display_flip_y = !g_display_flip_y;
            invalidate_base_render_cache();
            set_status("Vertical display flip: %s.", g_display_flip_y ? "on" : "off");
            break;
        case 'H':
            g_display_flip_x = !g_display_flip_x;
            invalidate_base_render_cache();
            set_status("Horizontal display flip: %s.", g_display_flip_x ? "on" : "off");
            break;
        case 'G':
            g_coast_guard_cells = (g_coast_guard_cells == 0) ? 1 : 0;
            rebuild_mask_and_reset();
            break;
        case 'F':
            g_double_buffer_enabled = !g_double_buffer_enabled;
            set_status("Low-flicker double buffering: %s.", g_double_buffer_enabled ? "on" : "off");
            break;
        case 'M':
            g_show_source_markers = !g_show_source_markers;
            set_status("Source markers: %s. Source data is still active.",
                       g_show_source_markers ? "shown" : "hidden");
            break;
        case 'P':
            g_show_coast_height_markers = !g_show_coast_height_markers;
            set_status("Coastal height markers: %s.", g_show_coast_height_markers ? "shown" : "hidden");
            break;
        case 'K':
            g_coast_marker_use_peak = !g_coast_marker_use_peak;
            set_status("Coastal height marker mode: %s.", g_coast_marker_use_peak ? "peak so far" : "live current height");
            break;
        case 'A':
            set_coastal_reflection_absorption(!coastal_reflection_absorption_enabled(),
                                              coastal_reflection_absorption_energy_loss());
            set_status("Coastal boundary absorption: %s. Energy loss = %.0f%%.",
                       coastal_reflection_absorption_enabled() ? "on" : "off",
                       coastal_reflection_absorption_energy_loss() * 100.0f);
            break;
        case '3': {
            float step = (GetKeyState(VK_SHIFT) & 0x8000) ? 0.01f : 0.05f;
            float loss = clampf_gui(coastal_reflection_absorption_energy_loss() - step, 0.0f, 0.95f);
            set_coastal_reflection_absorption(coastal_reflection_absorption_enabled(), loss);
            set_status("Coastal boundary energy loss = %.0f%%. Reflection R = %.4f.",
                       loss * 100.0f, coastal_reflection_keep_per_step());
            break;
        }
        case '4': {
            float step = (GetKeyState(VK_SHIFT) & 0x8000) ? 0.01f : 0.05f;
            float loss = clampf_gui(coastal_reflection_absorption_energy_loss() + step, 0.0f, 0.95f);
            set_coastal_reflection_absorption(coastal_reflection_absorption_enabled(), loss);
            set_status("Coastal boundary energy loss = %.0f%%. Reflection R = %.4f.",
                       loss * 100.0f, coastal_reflection_keep_per_step());
            break;
        }
        case '5':
            adjust_wave_display_coeff(1.0f / 1.25f);
            break;
        case '6':
            adjust_wave_display_coeff(1.25f);
            break;
        case '7':
            g_wave_overlay_alpha = clampf_gui(g_wave_overlay_alpha - 0.05f, 0.0f, 1.0f);
            set_status("Wave opacity = %.0f%%.", g_wave_overlay_alpha * 100.0f);
            break;
        case '8':
            g_wave_overlay_alpha = clampf_gui(g_wave_overlay_alpha + 0.05f, 0.0f, 1.0f);
            set_status("Wave opacity = %.0f%%.", g_wave_overlay_alpha * 100.0f);
            break;
        case 'W':
            g_wave_layer_mode = (g_wave_layer_mode + 1) % 3;
            set_status("Wave overlay layer = %s.", wave_layer_name());
            break;
        case 'O':
            save_max_wave_png_dialog(hwnd);
            return 0;
        case 'I':
            g_strike_slip_sense = -g_strike_slip_sense;
            set_status("Strike-slip sense = %s.", strike_slip_sense_name());
            break;
        case 'B':
            if (g_debug_key_armed) {
                g_debug_key_armed = 0;
                g_show_bathymetry = !g_show_bathymetry;
                invalidate_base_render_cache();
                set_status("Bathymetry debug base map: %s.", g_show_bathymetry ? "shown" : "hidden");
            } else {
                g_show_time_series_markers = !g_show_time_series_markers;
                set_status("Selected time-series station rings: %s.", g_show_time_series_markers ? "shown" : "hidden");
            }
            break;
        case 'T': {
            int shift_down = (GetKeyState(VK_SHIFT) & 0x8000) != 0;
            if (g_hover_station_i >= 0) {
                if (shift_down) {
                    time_series_add_station(g_hover_station_i, 0);
                } else {
                    time_series_select_station(g_hover_station_i);
                }
                if (!g_running) g_show_time_series_markers = 1;
            } else if (!shift_down && g_ts_rec_count > 0) {
                size_t old_count = g_ts_rec_count;
                time_series_free();
                g_show_time_series_markers = 0;
                set_status("Cleared time-series station selection. Previous selection had %zu station(s).", old_count);
            } else {
                set_status("Hover an observation marker, then press T. Use Shift+T to add multiple stations.");
                MessageBeep(MB_ICONWARNING);
            }
            break;
        }
        case 'Y':
            save_time_series_dialog(hwnd);
            return 0;
        case VK_ESCAPE:
            DestroyWindow(hwnd);
            return 0;
        }
        settings_refresh();
        InvalidateRect(hwnd, NULL, FALSE);
        return 0;

    case WM_PAINT: {
        PAINTSTRUCT ps;
        HDC hdc = BeginPaint(hwnd, &ps);
        RECT rc;
        GetClientRect(hwnd, &rc);
        int client_w = rc.right - rc.left;
        int client_h = rc.bottom - rc.top;

        if (g_double_buffer_enabled && client_w > 0 && client_h > 0) {
            HDC memdc = CreateCompatibleDC(hdc);
            HBITMAP backbmp = CreateCompatibleBitmap(hdc, client_w, client_h);
            HBITMAP oldbmp = (HBITMAP)SelectObject(memdc, backbmp);

            draw_scene_to_hdc(memdc, hwnd);

            BitBlt(hdc, 0, 0, client_w, client_h, memdc, 0, 0, SRCCOPY);
            SelectObject(memdc, oldbmp);
            DeleteObject(backbmp);
            DeleteDC(memdc);
        } else {
            draw_scene_to_hdc(hdc, hwnd);
        }

        EndPaint(hwnd, &ps);
        return 0;
    }

    case WM_DESTROY:
        if (g_settings_hwnd) DestroyWindow(g_settings_hwnd);
        KillTimer(hwnd, TIMER_ID);
        PostQuitMessage(0);
        return 0;
    }
    return DefWindowProc(hwnd, msg, wparam, lparam);
}

static int file_exists(const char *path) {
    FILE *fp = fopen(path, "rb");
    if (!fp) return 0;
    fclose(fp);
    return 1;
}

static void path_join_dir_file(const char *reference_path, const char *file_name, char *out, size_t out_size) {
    const char *slash1 = strrchr(reference_path, '\\');
    const char *slash2 = strrchr(reference_path, '/');
    const char *slash = slash1 > slash2 ? slash1 : slash2;
    if (slash) {
        size_t dir_len = (size_t)(slash - reference_path + 1);
        if (dir_len >= out_size) dir_len = out_size - 1;
        snprintf(out, out_size, "%.*s%s", (int)dir_len, reference_path, file_name);
    } else {
        snprintf(out, out_size, "%s", file_name);
    }
}

static int auto_load_default_coast_override(GridData *grid, const char *data_path, char *loaded_path, size_t loaded_path_size) {
    const char *file_name = "coast_override.csv";
    char candidate[MAX_PATH];

    if (file_exists(file_name) && load_land_override_csv(grid, file_name)) {
        snprintf(loaded_path, loaded_path_size, "%s", file_name);
        return 1;
    }

    path_join_dir_file(data_path, file_name, candidate, sizeof(candidate));
    if (strcmp(candidate, file_name) != 0 && file_exists(candidate) && load_land_override_csv(grid, candidate)) {
        snprintf(loaded_path, loaded_path_size, "%s", candidate);
        return 1;
    }

    char exe_path[MAX_PATH];
    DWORD n = GetModuleFileNameA(NULL, exe_path, MAX_PATH);
    if (n > 0 && n < MAX_PATH) {
        path_join_dir_file(exe_path, file_name, candidate, sizeof(candidate));
        if (file_exists(candidate) && load_land_override_csv(grid, candidate)) {
            snprintf(loaded_path, loaded_path_size, "%s", candidate);
            return 1;
        }
    }

    if (loaded_path_size) loaded_path[0] = '\0';
    return 0;
}

int main(int argc, char **argv) {
    const char *data_path = (argc >= 2) ? argv[1] : "tsunami_data.bin";
    if (!file_exists(data_path)) {
        MessageBoxA(NULL,
                    "Could not find tsunami_data.bin. Put it next to tsunami_gui.exe or pass it as the first command-line argument.",
                    "Tsunami GUI", MB_OK | MB_ICONERROR);
        return 1;
    }

    g_grid = load_grid_data(data_path);
    char override_path[MAX_PATH];
    if (auto_load_default_coast_override(&g_grid, data_path, override_path, sizeof(override_path))) {
        rebuild_solid_mask(&g_grid, g_coast_guard_cells);
        set_status("Default coast correction loaded. Use Settings for controls.");
    } else {
        rebuild_solid_mask(&g_grid, g_coast_guard_cells);
        set_status("No coast_override.csv found. Generated mask only.");
    }
    int virtual_coast_added = jesim_add_virtual_coast_observation_stations(&g_grid);
    if (virtual_coast_added > 0) {
        set_status("Added %d virtual coastal observation station(s) from JMA gauges without changing land mask.", virtual_coast_added);
    }
    g_sim = create_simulation(&g_grid);
    g_pixels = (uint32_t *)tsunami_xcalloc((size_t)g_grid.width * (size_t)g_grid.height, sizeof(uint32_t));
    g_base_pixels = (uint32_t *)tsunami_xcalloc((size_t)g_grid.width * (size_t)g_grid.height, sizeof(uint32_t));

    if (auto_load_color_settings()) {
        set_status("Loaded saved color settings. Use Settings > Colors to edit or reset.");
    }

    memset(&g_bmi, 0, sizeof(g_bmi));
    g_bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
    g_bmi.bmiHeader.biWidth = (LONG)g_grid.width;
    g_bmi.bmiHeader.biHeight = -(LONG)g_grid.height;
    g_bmi.bmiHeader.biPlanes = 1;
    g_bmi.bmiHeader.biBitCount = 32;
    g_bmi.bmiHeader.biCompression = BI_RGB;

    HINSTANCE hinst = GetModuleHandleA(NULL);
    WNDCLASSA wc;
    memset(&wc, 0, sizeof(wc));
    wc.lpfnWndProc = wnd_proc;
    wc.hInstance = hinst;
    wc.lpszClassName = "TsunamiGuiWindowClass";
    wc.hCursor = LoadCursor(NULL, IDC_ARROW);
    // Keep this NULL and handle WM_ERASEBKGND ourselves to avoid animation flicker.
    wc.hbrBackground = NULL;

    if (!RegisterClassA(&wc)) {
        MessageBoxA(NULL, "RegisterClass failed.", "Tsunami GUI", MB_OK | MB_ICONERROR);
        return 1;
    }

    init_gui_text_font();

    HWND hwnd = CreateWindowExA(0, wc.lpszClassName, "Tsunami Simulation GUI - Default Coast Mask + Strong Absorber",
                               WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN,
                               CW_USEDEFAULT, CW_USEDEFAULT, 1280, 760,
                               NULL, NULL, hinst, NULL);
    if (!hwnd) {
        MessageBoxA(NULL, "CreateWindow failed.", "Tsunami GUI", MB_OK | MB_ICONERROR);
        return 1;
    }

    ShowWindow(hwnd, SW_SHOW);
    UpdateWindow(hwnd);

    MSG msg;
    while (GetMessage(&msg, NULL, 0, 0) > 0) {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    }

    if (g_text_font) {
        DeleteObject(g_text_font);
        g_text_font = NULL;
    }
    time_series_free();
    jesim_free_export_state();
    free(g_pixels);
    free(g_base_pixels);
    source_list_free(&g_sources);
    free_simulation(&g_sim);
    free_grid_data(&g_grid);
    return 0;
}
