/*
  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, F flatten existing patches, 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_QUICK_FF_APPLY 2300
#define ID_QUICK_FF_COLOR_SLIP 2301
#define ID_QUICK_FF_COLOR_DELAY 2302
#define ID_QUICK_FF_ADD_ROW 2303
#define ID_QUICK_FF_CLEAR 2304
#define ID_QUICK_FF_CLOSE 2305
#define ID_QUICK_FF_RESIZE 2306
#define ID_QUICK_FF_ROWS_EDIT 2307
#define ID_QUICK_FF_COLS_EDIT 2308
#define ID_QUICK_FF_LOAD 2309
#define ID_QUICK_FF_SAVE 2310
#define ID_QUICK_FF_LEN_EDIT 2311
#define ID_QUICK_FF_WIDTH_EDIT 2312
#define ID_QUICK_FF_VIEW_INFO 2313
#define ID_QUICK_FF_MW_PREVIEW 2314
#define ID_QUICK_FF_DEPTH_DIR_TOGGLE 2315
#define ID_QUICK_FF_AUTO_DELAY 2316
#define ID_QUICK_FF_SLIP_PERCENT_EDIT 2317
#define ID_QUICK_FF_APPLY_SLIP_PERCENT 2318
#define ID_QUICK_FF_DIP_EDIT 2319
#define ID_QUICK_FF_APPLY_DIP_ALL 2320
#define ID_QUICK_FF_EDIT_BASE 2400
#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_SET_TAB_PETAL 2086
#define ID_SET_PETAL_MAIN_OUT 2110
#define ID_SET_PETAL_MAIN_STRIKE 2111
#define ID_SET_PETAL_OPPOSITE_LEN 2112
#define ID_SET_PETAL_END_OUT 2113
#define ID_SET_PETAL_END_STRIKE 2114
#define ID_SET_PETAL_CORNER_TILT 2115
#define ID_SET_LBL_PETAL_MAIN_OUT 2120
#define ID_SET_LBL_PETAL_MAIN_STRIKE 2121
#define ID_SET_LBL_PETAL_OPPOSITE_LEN 2122
#define ID_SET_LBL_PETAL_END_OUT 2123
#define ID_SET_LBL_PETAL_END_STRIKE 2124
#define ID_SET_LBL_PETAL_CORNER_TILT 2125
#define ID_SET_LBL_PETAL_STATUS 2126
#define ID_SET_PETAL_RESET 2130
#define ID_SET_PETAL_REGEN 2131
#define ID_SET_PETAL_APPLY 2132
#define ID_SET_EDIT_PETAL_MAIN_OUT 2140
#define ID_SET_EDIT_PETAL_MAIN_STRIKE 2141
#define ID_SET_EDIT_PETAL_OPPOSITE_LEN 2142
#define ID_SET_EDIT_PETAL_END_OUT 2143
#define ID_SET_EDIT_PETAL_END_STRIKE 2144
#define ID_SET_EDIT_PETAL_CORNER_GAP 2145
#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 int g_last_preset_recalculated_from_slip = 0;
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 = 4; /* v125: 15s tick; 4 steps/frame keeps the old 60s/frame default pace */
static float g_new_source_height_m = 1.0f;
static int g_new_source_delay_s = DEFAULT_DT_SECONDS;
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 const double MOMENT_CALIBRATION_SCALE = 2.0; // v148: apply empirical x2 M0 calibration
static float g_subsidence_ratio = 0.45f;    // depression amplitude relative to the uplift amount
/* Temporary V68 clover-petal tuning sliders.  These are empirical shape knobs
   for automatic secondary deformation while the final geometry is being tuned. */
static float g_clover_main_out_axis = 0.50f;      // side leaf outward length scale vs V63 baseline
static float g_clover_main_strike_full = 1.40f;   // visible full strike-parallel axis / fault strike length
static float g_clover_opposite_len = 1.50f;       // opposite side leaf length vs existing side leaf
static float g_clover_end_out_offset = 3.50f;     // end petals outward offset multiplier
static float g_clover_end_strike_len = 3.50f;     // end petals strike-parallel length multiplier
static float g_clover_corner_tilt_deg = 2.00f;    // corner outward gap multiplier for the 4-ellipse model
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.  v83 exports
   explicit per-record peak-update booleans in addition to rise == "+", so the
   websocket consumer can directly tell whether each observation point updated
   its own maximum.  The packet-level meaningful flag remains 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_quick_ff_hwnd = NULL;
static HWND g_panel_buttons[9] = {0};

#define QUICK_FF_GRID_COLS 60  /* maximum logical columns */
#define QUICK_FF_GRID_ROWS 60  /* maximum logical rows */
#define QUICK_FF_MAX_ROWS (QUICK_FF_GRID_COLS * QUICK_FF_GRID_ROWS)
#define QUICK_FF_VIEW_COLS 5   /* visible bingo-style columns */
#define QUICK_FF_VIEW_ROWS 5   /* visible bingo-style rows */
#define QUICK_FF_MIN_COLS 3    /* C3 must exist */
#define QUICK_FF_MIN_ROWS 3
#define QUICK_FF_ORIGIN_COL 2  /* C */
#define QUICK_FF_ORIGIN_ROW 2  /* 3 */
#define QUICK_FF_COLS QUICK_FF_GRID_COLS
typedef struct {
    int enabled;
    float rel_s;
    float rel_d;
    float slip_m;
    int delay_s;
    float dip_deg;
} QuickFiniteFaultRow;
static QuickFiniteFaultRow g_quick_ff_rows[QUICK_FF_MAX_ROWS];
static int g_quick_ff_count = 0;
static int g_quick_ff_grid_cols = 7;
static int g_quick_ff_grid_rows = 7;
static int g_quick_ff_view_col = 0;
static int g_quick_ff_view_row = 0;
static int g_quick_ff_color_mode = 0; /* 0 slip, 1 delay */
static int g_quick_ff_row1_is_shallow = 1; /* 1: row 1 is up-dip/shallow; 0: row 1 is down-dip/deep */
static float g_quick_ff_slip_percent = 100.0f;
static float g_quick_ff_bulk_dip_deg = 0.0f;
static int g_quick_ff_updating_controls = 0; /* suppress EN_CHANGE while programmatically filling the grid */
static HWND g_quick_ff_col_label_hwnd[QUICK_FF_VIEW_COLS] = {0};
static HWND g_quick_ff_row_label_hwnd[QUICK_FF_VIEW_ROWS] = {0};
static int g_quick_ff_place_mode = 0;
static int g_next_quick_ff_fault_id = 9000;

// 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 open_quick_finite_fault_editor(HWND owner);
static void quick_ff_draw_preview(HDC hdc);
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_depth_eff_table_interp(float d) {
    /*
       v127 calibrated thrust/reverse depth efficiency table.

       There is no universal observed scalar that converts slip at one depth to
       tsunami height: operational tsunami source models normally compute
       seafloor deformation using elastic dislocation/Okada-style geometry.
       For this fast simulator we keep a scalar multiplier, but anchor it to
       commonly used tsunami-source depth constraints:

         - shallow rupture is strongly more tsunamigenic than deep rupture;
         - most earthquake tsunamis are from events shallower than about 100 km;
         - many tsunami hazard scenario sets only use shallow interface sources
           to about 70 km;
         - slip deeper than about 150-200 km is treated as practically unable to
           create direct coseismic sea-surface displacement.

       The table is intentionally smooth and conservative, with a slower falloff
       in the 0-20 km zone than v80 so shallow subduction patches are not
       over-penalized, and a stronger cutoff beyond 70-100 km.
    */
    static const float depth_km[] = { 0.0f, 10.0f, 20.0f, 40.0f, 60.0f, 70.0f, 100.0f, 150.0f, 200.0f };
    static const float factor[]   = { 1.00f, 0.95f, 0.85f, 0.50f, 0.25f, 0.15f, 0.03f, 0.003f, 0.0f };
    const int n = (int)(sizeof(depth_km) / sizeof(depth_km[0]));
    if (d <= depth_km[0]) return factor[0];
    for (int i = 0; i < n - 1; ++i) {
        if (d <= depth_km[i + 1]) {
            float t = (d - depth_km[i]) / fmaxf(0.001f, depth_km[i + 1] - depth_km[i]);
            /* smootherstep avoids visible jumps in source height when a quick-FF
               patch crosses a table breakpoint during depth edits. */
            t = t * t * t * (t * (t * 6.0f - 15.0f) + 10.0f);
            return factor[i] + (factor[i + 1] - factor[i]) * t;
        }
    }
    return 0.0f;
}

static float thrust_tsunami_depth_factor_at_km(float depth_km) {
    float d = clampf_gui(depth_km, 0.0f, 700.0f);
    return clampf_gui(thrust_depth_eff_table_interp(d), 0.0f, 1.0f);
}

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 preset_recompute_height_from_slip_depth(float slip_m,
                                                     float dip_deg,
                                                     float depth_km) {
    /* v81: when loading a saved preset, treat slip_m as the authoritative
       finite-fault value and rebuild the injected water-height from the
       per-source dip/depth stored in the preset.  This lets old presets update
       automatically when the depth-efficiency curve changes, without changing
       the stored slip. */
    float slip = slip_m;
    if (slip < 0.0f) slip = -slip;
    if (slip <= SOURCE_SLIP_HEIGHT_EPS_M) return 0.0f;

    float dip = clampf_gui(dip_deg, 5.0f, 60.0f);
    float depth = clampf_gui(depth_km, 0.0f, 700.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 dip_rad = dip * TSUNAMI_PI / 180.0f;

    float reverse_h = slip * sinf(dip_rad) * reverse_mix *
                      thrust_tsunami_depth_factor_at_km(depth);
    float strike_h = slip * strike_mix * strike_slip_water_factor() *
                     strike_slip_tsunami_depth_factor_at_km(depth);
    float h = reverse_h + strike_h;
    if (fabsf(h) < 0.0005f) h = 0.0f;
    return h;
}

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 * MOMENT_CALIBRATION_SCALE;
}

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;

            /* v57: finite-fault stamp patches are treated as constant-slip
               rectangular tiles.  The older raised-cosine weight made one
               clicked/imported subfault patch look Gaussian inside itself,
               so adjacent subfaults produced speckled green/yellow pixels.
               A finite-fault model patch should instead be nearly uniform;
               smoothing is handled later by the secondary/halo generator, not
               inside each direct patch. */
            float w = 1.0f;

            /* v58: keep the depth factor constant inside one finite-fault
               subfault tile as well.  V57 removed the raised-cosine footprint
               weight, but local_depth_km still changed across the tile and
               left visible patch-internal mottling.  A clicked/imported
               subfault patch should behave as one nearly uniform cell of the
               finite-fault model. */
            float local_depth_km = patch_top_depth_km;
            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;
    int min_x, max_x;
    int min_y, max_y;
    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;
    double pos_h_abs_sum;
    double neg_h_abs_sum;
} 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->min_x = env->min_y = INT_MAX;
    env->max_x = env->max_y = INT_MIN;
    env->max_amp_m = 0.0f;
    env->pos_h_abs_sum = 0.0;
    env->neg_h_abs_sum = 0.0;
}

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;
    if (src->grid_x < env->min_x) env->min_x = src->grid_x;
    if (src->grid_x > env->max_x) env->max_x = src->grid_x;
    if (src->grid_y < env->min_y) env->min_y = src->grid_y;
    if (src->grid_y > env->max_y) env->max_y = src->grid_y;
    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;
    if (src->height_m > 0.0f) env->pos_h_abs_sum += (double)src->height_m;
    else if (src->height_m < 0.0f) env->neg_h_abs_sum += (double)(-src->height_m);
    env->source_cells++;
}

static int stamp_min_int(int a, int b) { return (a < b) ? a : b; }
static int stamp_max_int(int a, int b) { return (a > b) ? a : b; }
static int stamp_env_is_reverse_style(const StampFaultEnvelope *env) {
    if (!env) return 1;
    /* Uplift-dominant stamped patches are treated as reverse/thrust-like,
       whereas subsidence-dominant patches are treated as normal-fault-like.
       This keeps the new point-click finite-fault workflow aligned with the
       already painted brush result without requiring a separate manual toggle. */
    if (env->pos_h_abs_sum <= 0.0 && env->neg_h_abs_sum <= 0.0) return 1;
    return (env->pos_h_abs_sum >= env->neg_h_abs_sum) ? 1 : 0;
}

static float stamp_teardrop_lobe_weight(float ds, float dn,
                                        float center_s, float center_n,
                                        float dir_s, float dir_n,
                                        float sigma_front,
                                        float sigma_back,
                                        float sigma_side) {
    float rs = ds - center_s;
    float rn = dn - center_n;
    float u = rs * dir_s + rn * dir_n;
    float v = -rs * dir_n + rn * dir_s;
    float su = (u >= 0.0f) ? sigma_front : sigma_back;
    if (su < 0.8f) su = 0.8f;
    if (sigma_side < 0.8f) sigma_side = 0.8f;
    return expf(-0.5f * ((u * u) / (su * su) + (v * v) / (sigma_side * sigma_side)));
}

static float stamp_round_lobe_weight(float ds, float dn,
                                     float center_s, float center_n,
                                     float sigma_s, float sigma_n) {
    float rs = ds - center_s;
    float rn = dn - center_n;
    if (sigma_s < 0.8f) sigma_s = 0.8f;
    if (sigma_n < 0.8f) sigma_n = 0.8f;
    return expf(-0.5f * ((rs * rs) / (sigma_s * sigma_s) + (rn * rn) / (sigma_n * sigma_n)));
}


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));
    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_mark_fault_patch_mask(uint8_t *fault_mask,
                                        const StampFaultEnvelope *env,
                                        const SourceSpec *src) {
    if (!fault_mask || !env || !src) return;
    if (src->grid_x < 0 || src->grid_y < 0 || src->grid_x >= (int)g_grid.width || src->grid_y >= (int)g_grid.height) return;

    float km_per_cell = local_ground_km_per_cell_mercator((float)src->grid_y);
    if (km_per_cell < 0.01f) km_per_cell = 0.01f;

    float local_length_km = (src->stamp_length_km > 0.0f) ? normalized_stamp_length_km(src->stamp_length_km)
                                                           : normalized_stamp_length_km(env->length_km);
    float local_width_km = (src->stamp_width_km > 0.0f) ? normalized_fault_width_km(src->stamp_width_km)
                                                         : normalized_fault_width_km(env->width_km);
    float local_dip_deg = (src->stamp_dip_deg > 0.0f) ? clampf_gui(src->stamp_dip_deg, 5.0f, 60.0f)
                                                       : clampf_gui(env->dip_deg, 5.0f, 60.0f);
    float length_cells = fmaxf(1.6f, local_length_km / km_per_cell);
    float width_cells = fmaxf(1.4f, (local_width_km * fault_dip_cos_deg(local_dip_deg)) / km_per_cell);
    float half_s = 0.5f * length_cells + 0.75f;
    float half_n = 0.5f * width_cells + 0.70f;
    int search_r = (int)ceilf(sqrtf(half_s * half_s + half_n * half_n) + 2.0f);

    for (int y = src->grid_y - search_r; y <= src->grid_y + search_r; ++y) {
        if (y < 0 || y >= (int)g_grid.height) continue;
        for (int x = src->grid_x - search_r; x <= src->grid_x + search_r; ++x) {
            if (x < 0 || x >= (int)g_grid.width) continue;
            float ds = ((float)x - (float)src->grid_x) * env->sx + ((float)y - (float)src->grid_y) * env->sy;
            float dn = ((float)x - (float)src->grid_x) * env->nx + ((float)y - (float)src->grid_y) * env->ny;
            float as = fabsf(ds) / fmaxf(1.0f, half_s);
            float an = fabsf(dn) / fmaxf(1.0f, half_n);
            if (rounded_superellipse_weight(as, an, 3.0f) > 0.0f) {
                size_t idx = tsunami_index(&g_grid, (uint32_t)x, (uint32_t)y);
                if (!g_grid.solid[idx]) fault_mask[idx] = 1;
            }
        }
    }
}

static void stamp_fill_fault_mask_gaps(uint8_t *fault_mask, const StampFaultEnvelope *env) {
    if (!fault_mask || !env || !env->valid) return;
    const size_t cell_count = (size_t)g_grid.width * (size_t)g_grid.height;
    uint8_t *tmp = (uint8_t *)calloc(cell_count, 1);
    if (!tmp) return;

    int pad = 4;
    int minx = (env->min_x == INT_MAX) ? 0 : stamp_max_int(0, env->min_x - pad);
    int maxx = (env->max_x == INT_MIN) ? ((int)g_grid.width - 1) : stamp_min_int((int)g_grid.width - 1, env->max_x + pad);
    int miny = (env->min_y == INT_MAX) ? 0 : stamp_max_int(0, env->min_y - pad);
    int maxy = (env->max_y == INT_MIN) ? ((int)g_grid.height - 1) : stamp_min_int((int)g_grid.height - 1, env->max_y + pad);

    for (int iter = 0; iter < 2; ++iter) {
        memcpy(tmp, fault_mask, cell_count);
        for (int y = miny; y <= maxy; ++y) {
            for (int x = minx; x <= maxx; ++x) {
                size_t idx = tsunami_index(&g_grid, (uint32_t)x, (uint32_t)y);
                if (g_grid.solid[idx]) continue;
                int count = 0;
                for (int dy = -1; dy <= 1; ++dy) {
                    for (int dx = -1; dx <= 1; ++dx) {
                        if (dx == 0 && dy == 0) continue;
                        int xx = x + dx, yy = y + dy;
                        if (xx < 0 || yy < 0 || xx >= (int)g_grid.width || yy >= (int)g_grid.height) continue;
                        size_t nidx = tsunami_index(&g_grid, (uint32_t)xx, (uint32_t)yy);
                        if (fault_mask[nidx]) count++;
                    }
                }
                if (!fault_mask[idx]) {
                    if (count >= 5) tmp[idx] = 1;
                } else {
                    if (count <= 1) tmp[idx] = 0;
                }
            }
        }
        memcpy(fault_mask, tmp, cell_count);
    }
    free(tmp);
}

static void stamp_build_fault_mask_for_envelope(uint8_t *fault_mask,
                                                uint8_t *occupied,
                                                const StampFaultEnvelope *env,
                                                const SourceSpec *sources,
                                                size_t source_count) {
    if (!fault_mask || !occupied || !env || !sources || env->source_cells <= 0) return;
    for (size_t i = 0; i < source_count; ++i) {
        const SourceSpec *src = &sources[i];
        if (!stamp_source_is_direct_rupture_stamp(src)) continue;
        if (src->fault_id != env->fault_id) continue;
        stamp_mark_fault_patch_mask(fault_mask, env, src);
    }
    stamp_fill_fault_mask_gaps(fault_mask, env);

    int pad = 1;
    int minx = (env->min_x == INT_MAX) ? 0 : stamp_max_int(0, env->min_x - pad);
    int maxx = (env->max_x == INT_MIN) ? ((int)g_grid.width - 1) : stamp_min_int((int)g_grid.width - 1, env->max_x + pad);
    int miny = (env->min_y == INT_MAX) ? 0 : stamp_max_int(0, env->min_y - pad);
    int maxy = (env->max_y == INT_MIN) ? ((int)g_grid.height - 1) : stamp_min_int((int)g_grid.height - 1, env->max_y + pad);
    for (int y = miny; y <= maxy; ++y) {
        for (int x = minx; x <= maxx; ++x) {
            size_t idx = tsunami_index(&g_grid, (uint32_t)x, (uint32_t)y);
            if (fault_mask[idx]) 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;
    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;
    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_mask(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 uint8_t *fault_mask,
                                              const StampFaultEnvelope *env) {
    if (!env || !fault_mask || !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 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);
    float stamp_length_km = normalized_stamp_length_km(env->length_km);
    float stamp_length_cells = fmaxf(2.0f, stamp_length_km / km_per_cell);
    float footprint_length_cells = fmaxf(2.0f, env->max_s - env->min_s);
    float fault_length_cells = fmaxf(footprint_length_cells, stamp_length_cells);
    float fault_length_km = fmaxf(stamp_length_km, fault_length_cells * km_per_cell);
    float fault_width_km = normalized_fault_width_km(env->width_km);
    float patch_area_km2 = fmaxf(1.0f, stamp_length_km * fault_width_km);
    float fault_area_km2 = fmaxf(patch_area_km2, fault_length_km * fault_width_km);
    float area_scale = sqrtf(fault_area_km2 / patch_area_km2);
    float secondary_amp_scale = clampf_gui(0.84f + 0.06f * (area_scale - 1.0f), 0.84f, 1.22f);

    float ratio = clampf_gui(g_subsidence_ratio, 0.0f, 1.0f);
    float base_amp = env->max_amp_m;
    int subs_side = (env->subsidence_side < 0) ? -1 : 1;
    int reverse_style = stamp_env_is_reverse_style(env);

    /* v61: keep the discrete clover petal logic from v60, but make the petals
       less flat and more compact.  The user's feedback was that the leaves were
       too broad.  So the main/partner/end petals are all narrowed, moved a bit
       farther from the fault, and stretched more along their pointing axis. */
    float half_len = fmaxf(2.5f, 0.5f * fault_length_cells);
    float main_side = (float)(reverse_style ? -subs_side : subs_side);
    float max_reach = fmaxf(10.0f, half_len + 3.6f * width_cells);
    int pad = (int)ceilf(max_reach) + 5;
    int minx = (env->min_x == INT_MAX) ? 0 : stamp_max_int(0, env->min_x - pad);
    int maxx = (env->max_x == INT_MIN) ? ((int)g_grid.width - 1) : stamp_min_int((int)g_grid.width - 1, env->max_x + pad);
    int miny = (env->min_y == INT_MAX) ? 0 : stamp_max_int(0, env->min_y - pad);
    int maxy = (env->max_y == INT_MIN) ? ((int)g_grid.height - 1) : stamp_min_int((int)g_grid.height - 1, env->max_y + pad);

    /* v73: simplify the clover into 4 ellipses that are shifted slightly
       outward from the fault body, matching the user's sketch more directly.
       No tilt is used here: two side ellipses and two end ellipses, each
       aligned with the local strike/normal axes, plus an extra corner-gap
       multiplier that pushes the ellipses outward to create a sharper acute
       junction near the corners. */
    float corner_gap = g_clover_corner_tilt_deg;

    /* v75: the previous centers were still too close to the fault body, so the
       mask clipped the inner halves and the ellipses looked like split halves
       attached to the ends.  Push all 4 ellipse centers farther outward from
       the fault before evaluating their fields, and expand the corner-gap range
       so the user can separate them much more strongly. */
    float side_shift_n = ((1.10f + 0.45f * corner_gap) * width_cells + 1.00f);
    float major_center_s = 0.00f;
    float major_center_n = main_side * side_shift_n;
    float major_sigma_s  = fmaxf(1.6f, 0.50f * g_clover_main_strike_full * half_len);
    float major_sigma_n  = fmaxf(1.3f, 0.468f * g_clover_main_out_axis * half_len);

    float minor_center_s = 0.00f;
    float minor_center_n = -main_side * (side_shift_n + 0.18f * width_cells);
    float minor_sigma_s  = fmaxf(1.6f, 0.50f * g_clover_main_strike_full * half_len);
    float minor_sigma_n  = fmaxf(1.3f, g_clover_opposite_len * 0.468f * g_clover_main_out_axis * half_len);

    float end_center_s = fmaxf(2.4f, (1.45f + 0.28f * g_clover_end_out_offset + 0.22f * corner_gap) * half_len);
    float end_center_n = 0.0f;
    float end_sigma_s  = fmaxf(2.0f, 0.25f * g_clover_end_strike_len * half_len);
    float end_sigma_n  = fmaxf(1.2f, 0.20f * width_cells);

    float sign_major = reverse_style ? 1.0f : -1.0f;
    float sign_minor = sign_major;
    float sign_end = -sign_major;

    for (int y = miny; y <= maxy; ++y) {
        for (int x = minx; x <= maxx; ++x) {
            size_t idx = tsunami_index(&g_grid, (uint32_t)x, (uint32_t)y);
            if (fault_mask[idx]) 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 w_major = stamp_round_lobe_weight(ds, dn,
                                                    major_center_s, major_center_n,
                                                    major_sigma_s, major_sigma_n);
            float w_minor = stamp_round_lobe_weight(ds, dn,
                                                    minor_center_s, minor_center_n,
                                                    minor_sigma_s, minor_sigma_n);
            float w_top = stamp_round_lobe_weight(ds, dn,
                                                  end_center_s, end_center_n,
                                                  end_sigma_s, end_sigma_n);
            float w_bottom = stamp_round_lobe_weight(ds, dn,
                                                     -end_center_s, end_center_n,
                                                     end_sigma_s, end_sigma_n);

            float c_major = sign_major * base_amp * ratio * 0.46f * secondary_amp_scale * w_major;
            float c_minor = sign_minor * base_amp * ratio * 0.34f * secondary_amp_scale * w_minor;
            float c_top   = sign_end   * base_amp * ratio * 0.17f * secondary_amp_scale * w_top;
            float c_bottom= sign_end   * base_amp * ratio * 0.17f * secondary_amp_scale * w_bottom;

            float candidate = c_major;
            float best_abs = fabsf(candidate);
            if (fabsf(c_minor) > best_abs) { candidate = c_minor; best_abs = fabsf(c_minor); }
            if (fabsf(c_top) > best_abs)   { candidate = c_top;   best_abs = fabsf(c_top); }
            if (fabsf(c_bottom) > best_abs){ candidate = c_bottom;best_abs = fabsf(c_bottom); }
            if (best_abs < 0.0015f) continue;

            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;

    /* v78: automatic secondary deformation is intentionally disabled.
       The empirical petal/ellipse secondary field could not reproduce the
       desired corner geometry reliably, so regeneration now only removes old
       auto-generated secondary nodes and leaves the finite-fault direct source
       patches unchanged.  Patch flattening and finite-fault stamping remain
       available. */
    int removed = stamp_remove_auto_subsidence_sources();
    if (g_stamp_fault_count <= 0) stamp_runtime_sources_max_fault_id();

    sanitize_all_source_node_slips();
    if (reset_after) reset_state_preserve_sources();
    if (show_status) {
        set_status("Auto secondary deformation is disabled: removed %d old auto-secondary node(s); finite-fault direct patches remain unchanged.", removed);
    }
    return 0;
}


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));
    }
}

typedef struct {
    int fault_id;
    int patch_id;
    int count;
    float strongest_h;
    float strongest_abs_h;
} StampPatchFlattenAccum;

static int stamp_flatten_existing_direct_patches(HWND hwnd, int reset_after, int regen_secondary) {
    (void)hwnd;
    size_t source_count = g_sources.count;
    if (source_count == 0) {
        set_status("Flatten stamp patches: no sources to adjust.");
        return 0;
    }

    StampPatchFlattenAccum *acc = (StampPatchFlattenAccum *)calloc(source_count, sizeof(StampPatchFlattenAccum));
    if (!acc) {
        set_status("Flatten stamp patches: out of memory.");
        return 0;
    }

    int patch_count = 0;
    for (size_t i = 0; i < source_count; ++i) {
        SourceSpec *src = &g_sources.items[i];
        if (!stamp_source_is_direct_rupture_stamp(src)) continue;
        int j = -1;
        for (int k = 0; k < patch_count; ++k) {
            if (acc[k].fault_id == src->fault_id && acc[k].patch_id == src->fault_patch_id) {
                j = k;
                break;
            }
        }
        if (j < 0) {
            j = patch_count++;
            acc[j].fault_id = src->fault_id;
            acc[j].patch_id = src->fault_patch_id;
            acc[j].strongest_h = src->height_m;
            acc[j].strongest_abs_h = fabsf(src->height_m);
        }
        float ah = fabsf(src->height_m);
        if (ah > acc[j].strongest_abs_h) {
            acc[j].strongest_abs_h = ah;
            acc[j].strongest_h = src->height_m;
        }
        acc[j].count++;
    }

    if (patch_count <= 0) {
        free(acc);
        set_status("Flatten stamp patches: no direct finite-fault patches found.");
        return 0;
    }

    int adjusted = 0;
    for (size_t i = 0; i < source_count; ++i) {
        SourceSpec *src = &g_sources.items[i];
        if (!stamp_source_is_direct_rupture_stamp(src)) continue;
        for (int j = 0; j < patch_count; ++j) {
            if (acc[j].fault_id == src->fault_id && acc[j].patch_id == src->fault_patch_id) {
                float target_h = acc[j].strongest_h;
                if (fabsf(src->height_m - target_h) > 0.0005f) {
                    src->height_m = target_h;
                    adjusted++;
                }
                break;
            }
        }
    }
    free(acc);

    sanitize_all_source_node_slips();
    if (regen_secondary) {
        stamp_regenerate_auto_subsidence(hwnd, reset_after, 0);
    } else if (reset_after) {
        reset_state_preserve_sources();
    }
    set_status("Flatten stamp patches: adjusted %d source cells in %d existing direct patch(es). Each patch now uses its strongest signed height; auto secondary %s.",
               adjusted, patch_count, regen_secondary ? "regenerated" : "not regenerated");
    return adjusted;
}

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;
    case 'F':
        source_undo_push("flatten existing stamp patches");
        stamp_remove_auto_subsidence_sources();
        stamp_flatten_existing_direct_patches(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 solver_h = g_sim.height_norm ? g_sim.height_norm[p] : 0.0f;
    if (solver_h >= SCRATCH_LAND_VALUE * 0.5f) return 0.0f;

    float local_depth_m = visible_effective_depth_m(&g_grid, (size_t)grid_idx);
    float physical_h = solver_h * output_shoaling_factor(local_depth_m);
    return physical_h * station_shoreline_equiv_factor(local_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 exported explicitly as peak_updated / individual_peak_updated / max_updated, and rise=+ mirrors the same judgement; observed_time_s / observation_time_s remain first positive-arrival time for later height updates; 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. The same per-observation judgement is now explicitly exported as peak_updated, individual_peak_updated, max_updated, and is_new_individual_max. 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) {
            /* v118: keep observed_time_s fixed to the first positive-arrival time.
               Height/peak update bulletins still use the current 5-minute
               report bucket as model_s, but their observation timestamp remains
               the original first-arrival time for this gauge. */
            jesim_queue_observation_report(oi, g_time_s, arr_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, but observed_time_s / observation_time_s remain fixed to the first positive arrival time even when peak_updated is true; rise and explicit booleans peak_updated / individual_peak_updated / max_updated mark whether that observation point newly updated its displayed positive peak by 0.1m; packet_meaningful_update marks whether any observation in the whole packet updated its displayed peak; 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 exposed by peak_updated, individual_peak_updated, max_updated, is_new_individual_max, and mirrored by 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);
        fprintf(fp, ",\"observation_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, ",\"peak_updated\":%s", ev->meaningful_peak_update ? "true" : "false");
        fprintf(fp, ",\"individual_peak_updated\":%s", ev->meaningful_peak_update ? "true" : "false");
        fprintf(fp, ",\"max_updated\":%s", ev->meaningful_peak_update ? "true" : "false");
        fprintf(fp, ",\"is_new_individual_max\":%s", ev->meaningful_peak_update ? "true" : "false");
        fprintf(fp, ",\"peak_delta_dm\":%d", ev->peak_delta_dm);
        fprintf(fp, ",\"peak_delta_m\":%.1f", ((float)ev->peak_delta_dm) / 10.0f);
        fputs(",\"peak_update_scope\":\"individual_observation_point\"", fp);
        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);
    quick_ff_draw_preview(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;
}

typedef struct {
    int fault_id;
    int patch_id;
    int count;
    float sum_x;
    float sum_y;
    float sum_slip;
    int delay_min;
    float strike_deg;
    float dip_deg;
    float depth_km;
    float width_km;
    float length_km;
} FaultExportPatchAccum;

static void rgb24_fill(unsigned char *rgb, uint32_t w, uint32_t h, uint32_t color) {
    if (!rgb) return;
    unsigned char r = (unsigned char)((color >> 16) & 0xffu);
    unsigned char g = (unsigned char)((color >> 8) & 0xffu);
    unsigned char b = (unsigned char)(color & 0xffu);
    size_t pixels = (size_t)w * (size_t)h;
    for (size_t i = 0; i < pixels; ++i) {
        rgb[i * 3u + 0] = r;
        rgb[i * 3u + 1] = g;
        rgb[i * 3u + 2] = b;
    }
}

static void rgb24_put_pixel(unsigned char *rgb, uint32_t w, uint32_t h, int x, int y, uint32_t color) {
    if (!rgb) return;
    if (x < 0 || y < 0 || x >= (int)w || y >= (int)h) return;
    size_t p = ((size_t)y * (size_t)w + (size_t)x) * 3u;
    rgb[p + 0] = (unsigned char)((color >> 16) & 0xffu);
    rgb[p + 1] = (unsigned char)((color >> 8) & 0xffu);
    rgb[p + 2] = (unsigned char)(color & 0xffu);
}

static void rgb24_fill_rect(unsigned char *rgb, uint32_t w, uint32_t h,
                            int x0, int y0, int x1, int y1, uint32_t color) {
    if (!rgb) return;
    if (x0 > x1) { int t = x0; x0 = x1; x1 = t; }
    if (y0 > y1) { int t = y0; y0 = y1; y1 = t; }
    if (x1 < 0 || y1 < 0 || x0 >= (int)w || y0 >= (int)h) return;
    x0 = clampi_gui(x0, 0, (int)w - 1);
    x1 = clampi_gui(x1, 0, (int)w - 1);
    y0 = clampi_gui(y0, 0, (int)h - 1);
    y1 = clampi_gui(y1, 0, (int)h - 1);
    for (int y = y0; y <= y1; ++y) {
        for (int x = x0; x <= x1; ++x) {
            rgb24_put_pixel(rgb, w, h, x, y, color);
        }
    }
}

static void rgb24_draw_rect_outline(unsigned char *rgb, uint32_t w, uint32_t h,
                                    int x0, int y0, int x1, int y1, uint32_t color) {
    if (x0 > x1) { int t = x0; x0 = x1; x1 = t; }
    if (y0 > y1) { int t = y0; y0 = y1; y1 = t; }
    for (int x = x0; x <= x1; ++x) {
        rgb24_put_pixel(rgb, w, h, x, y0, color);
        rgb24_put_pixel(rgb, w, h, x, y1, color);
    }
    for (int y = y0; y <= y1; ++y) {
        rgb24_put_pixel(rgb, w, h, x0, y, color);
        rgb24_put_pixel(rgb, w, h, x1, y, color);
    }
}

static void rgb24_draw_text_gdi(unsigned char *rgb, uint32_t w, uint32_t h,
                                int x, int y, const char *text,
                                int font_px, int bold, uint32_t color) {
    if (!rgb || !text || !*text || w == 0 || h == 0) return;
    BITMAPINFO bmi;
    memset(&bmi, 0, sizeof(bmi));
    bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
    bmi.bmiHeader.biWidth = (LONG)w;
    bmi.bmiHeader.biHeight = -(LONG)h; /* top-down */
    bmi.bmiHeader.biPlanes = 1;
    bmi.bmiHeader.biBitCount = 24;
    bmi.bmiHeader.biCompression = BI_RGB;
    void *bits = NULL;
    HBITMAP bmp = CreateDIBSection(NULL, &bmi, DIB_RGB_COLORS, &bits, NULL, 0);
    if (!bmp || !bits) {
        if (bmp) DeleteObject(bmp);
        return;
    }
    int stride = (int)(((w * 3u) + 3u) & ~3u);
    unsigned char *dst = (unsigned char *)bits;
    for (uint32_t yy = 0; yy < h; ++yy) {
        for (uint32_t xx = 0; xx < w; ++xx) {
            size_t srcp = ((size_t)yy * (size_t)w + (size_t)xx) * 3u;
            size_t dstp = (size_t)yy * (size_t)stride + (size_t)xx * 3u;
            dst[dstp + 0] = rgb[srcp + 2];
            dst[dstp + 1] = rgb[srcp + 1];
            dst[dstp + 2] = rgb[srcp + 0];
        }
    }
    HDC dc = CreateCompatibleDC(NULL);
    if (!dc) {
        DeleteObject(bmp);
        return;
    }
    HGDIOBJ oldbmp = SelectObject(dc, bmp);
    HFONT font = CreateFontA(-font_px, 0, 0, 0, bold ? FW_BOLD : FW_NORMAL,
                             FALSE, FALSE, FALSE, ANSI_CHARSET,
                             OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS,
                             CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_DONTCARE,
                             "Arial");
    HGDIOBJ oldfont = font ? SelectObject(dc, font) : NULL;
    SetBkMode(dc, TRANSPARENT);
    SetTextColor(dc, RGB((color >> 16) & 0xffu, (color >> 8) & 0xffu, color & 0xffu));
    TextOutA(dc, x, y, text, (int)strlen(text));
    if (oldfont) SelectObject(dc, oldfont);
    if (font) DeleteObject(font);
    SelectObject(dc, oldbmp);
    DeleteDC(dc);
    for (uint32_t yy = 0; yy < h; ++yy) {
        for (uint32_t xx = 0; xx < w; ++xx) {
            size_t srcp = (size_t)yy * (size_t)stride + (size_t)xx * 3u;
            size_t dstp = ((size_t)yy * (size_t)w + (size_t)xx) * 3u;
            rgb[dstp + 0] = dst[srcp + 2];
            rgb[dstp + 1] = dst[srcp + 1];
            rgb[dstp + 2] = dst[srcp + 0];
        }
    }
    DeleteObject(bmp);
}

static void rgb24_draw_line(unsigned char *rgb, uint32_t w, uint32_t h,
                            int x0, int y0, int x1, int y1, uint32_t color) {
    int dx = abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
    int dy = -abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
    int err = dx + dy;
    for (;;) {
        rgb24_put_pixel(rgb, w, h, x0, y0, color);
        if (x0 == x1 && y0 == y1) break;
        int e2 = 2 * err;
        if (e2 >= dy) { err += dy; x0 += sx; }
        if (e2 <= dx) { err += dx; y0 += sy; }
    }
}

static uint32_t finite_fault_usgs_slip_rgb(float t) {
    typedef struct { float t; uint8_t r, g, b; } Stop;
    static const Stop stops[] = {
        {0.00f, 255, 255, 255},
        {0.15f, 255, 247, 188},
        {0.35f, 254, 227, 145},
        {0.55f, 254, 196, 79},
        {0.72f, 252, 141, 60},
        {0.88f, 227, 74, 51},
        {1.00f, 165, 15, 21}
    };
    if (t <= 0.0f) return 0xffffffu;
    if (t >= 1.0f) return 0xa50f15u;
    for (int i = 0; i < (int)(sizeof(stops) / sizeof(stops[0])) - 1; ++i) {
        if (t <= stops[i + 1].t) {
            float span = stops[i + 1].t - stops[i].t;
            float u = (span > 0.0f) ? (t - stops[i].t) / span : 0.0f;
            int r = (int)lroundf(stops[i].r + u * (float)((int)stops[i + 1].r - (int)stops[i].r));
            int g = (int)lroundf(stops[i].g + u * (float)((int)stops[i + 1].g - (int)stops[i].g));
            int b = (int)lroundf(stops[i].b + u * (float)((int)stops[i + 1].b - (int)stops[i].b));
            return ((uint32_t)r << 16) | ((uint32_t)g << 8) | (uint32_t)b;
        }
    }
    return 0xa50f15u;
}

static void finite_fault_export_draw_rotated_patch(unsigned char *rgb, uint32_t w, uint32_t h,
                                                   float cx, float cy,
                                                   float strike_deg,
                                                   float length_px, float width_px,
                                                   uint32_t fill_color, uint32_t line_color) {
    float rad = normalize_deg_gui(strike_deg) * (3.14159265358979323846f / 180.0f);
    float sx = sinf(rad);
    float sy = -cosf(rad);
    float nx = cosf(rad);
    float ny = sinf(rad);
    float half_s = 0.5f * fmaxf(length_px, 1.0f);
    float half_n = 0.5f * fmaxf(width_px, 1.0f);
    float rx = fabsf(sx) * half_s + fabsf(nx) * half_n + 2.0f;
    float ry = fabsf(sy) * half_s + fabsf(ny) * half_n + 2.0f;
    int minx = (int)floorf(cx - rx);
    int maxx = (int)ceilf(cx + rx);
    int miny = (int)floorf(cy - ry);
    int maxy = (int)ceilf(cy + ry);
    for (int y = miny; y <= maxy; ++y) {
        for (int x = minx; x <= maxx; ++x) {
            float dx = ((float)x + 0.5f) - cx;
            float dy = ((float)y + 0.5f) - cy;
            float ds = dx * sx + dy * sy;
            float dn = dx * nx + dy * ny;
            float ads = fabsf(ds);
            float adn = fabsf(dn);
            if (ads <= half_s && adn <= half_n) {
                int on_edge = (fabsf(ads - half_s) <= 0.90f) || (fabsf(adn - half_n) <= 0.90f);
                rgb24_put_pixel(rgb, w, h, x, y, on_edge ? line_color : fill_color);
            }
        }
    }
}

static int collect_finite_fault_export_patches(FaultExportPatchAccum **out_patches, int *out_count, float *out_max_slip) {
    if (out_patches) *out_patches = NULL;
    if (out_count) *out_count = 0;
    if (out_max_slip) *out_max_slip = 0.0f;
    if (!g_sources.items || g_sources.count == 0) return 0;

    FaultExportPatchAccum *acc = (FaultExportPatchAccum *)calloc(g_sources.count, sizeof(FaultExportPatchAccum));
    if (!acc) return 0;
    int count = 0;
    float max_slip = 0.0f;

    for (size_t i = 0; i < g_sources.count; ++i) {
        const SourceSpec *src = &g_sources.items[i];
        if (src->fault_id <= 0 || src->fault_patch_id <= 0) continue;
        float slip = src->slip_m;
        if (slip < 0.0f) slip = -slip;
        if (slip <= SOURCE_SLIP_HEIGHT_EPS_M) continue;
        int j;
        for (j = 0; j < count; ++j) {
            if (acc[j].fault_id == src->fault_id && acc[j].patch_id == src->fault_patch_id) break;
        }
        if (j >= count) {
            acc[j].fault_id = src->fault_id;
            acc[j].patch_id = src->fault_patch_id;
            acc[j].delay_min = src->delay_s;
            acc[j].strike_deg = src->stamp_strike_deg;
            acc[j].dip_deg = source_node_stamp_dip_deg(src);
            acc[j].depth_km = src->stamp_depth_km;
            acc[j].width_km = (src->stamp_width_km > 0.0f) ? normalized_fault_width_km(src->stamp_width_km) : current_fault_width_km();
            acc[j].length_km = (src->stamp_length_km > 0.0f) ? normalized_stamp_length_km(src->stamp_length_km) : normalized_stamp_length_km(g_fault_stamp_length_km);
            count++;
        }
        acc[j].count += 1;
        acc[j].sum_x += (float)src->grid_x;
        acc[j].sum_y += (float)src->grid_y;
        acc[j].sum_slip += slip;
        if (src->delay_s < acc[j].delay_min) acc[j].delay_min = src->delay_s;
        if (slip > max_slip) max_slip = slip;
    }

    if (count <= 0) {
        free(acc);
        return 0;
    }
    if (out_patches) *out_patches = acc; else free(acc);
    if (out_count) *out_count = count;
    if (out_max_slip) *out_max_slip = max_slip;
    return 1;
}


static uint32_t finite_fault_lerp_rgb(uint32_t c0, uint32_t c1, float t) {
    t = clampf_gui(t, 0.0f, 1.0f);
    int r0 = (int)((c0 >> 16) & 0xffu), g0 = (int)((c0 >> 8) & 0xffu), b0 = (int)(c0 & 0xffu);
    int r1 = (int)((c1 >> 16) & 0xffu), g1 = (int)((c1 >> 8) & 0xffu), b1 = (int)(c1 & 0xffu);
    int r = (int)lroundf((float)r0 + t * (float)(r1 - r0));
    int g = (int)lroundf((float)g0 + t * (float)(g1 - g0));
    int b = (int)lroundf((float)b0 + t * (float)(b1 - b0));
    return ((uint32_t)r << 16) | ((uint32_t)g << 8) | (uint32_t)b;
}

static void rgb24_blend_pixel(unsigned char *rgb, uint32_t w, uint32_t h, int x, int y, uint32_t color, float alpha) {
    if (!rgb) return;
    if (x < 0 || y < 0 || x >= (int)w || y >= (int)h) return;
    alpha = clampf_gui(alpha, 0.0f, 1.0f);
    size_t p = ((size_t)y * (size_t)w + (size_t)x) * 3u;
    int r0 = rgb[p + 0], g0 = rgb[p + 1], b0 = rgb[p + 2];
    int r1 = (int)((color >> 16) & 0xffu), g1 = (int)((color >> 8) & 0xffu), b1 = (int)(color & 0xffu);
    rgb[p + 0] = (unsigned char)clampi_gui((int)lroundf((1.0f - alpha) * (float)r0 + alpha * (float)r1), 0, 255);
    rgb[p + 1] = (unsigned char)clampi_gui((int)lroundf((1.0f - alpha) * (float)g0 + alpha * (float)g1), 0, 255);
    rgb[p + 2] = (unsigned char)clampi_gui((int)lroundf((1.0f - alpha) * (float)b0 + alpha * (float)b1), 0, 255);
}

static uint32_t finite_fault_map_water_rgb(float depth_m) {
    float t = clampf_gui(depth_m / 8000.0f, 0.0f, 1.0f);
    if (t < 0.33f) return finite_fault_lerp_rgb(0xd9ecffu, 0xa9d2f3u, t / 0.33f);
    if (t < 0.66f) return finite_fault_lerp_rgb(0xa9d2f3u, 0x5c99d3u, (t - 0.33f) / 0.33f);
    return finite_fault_lerp_rgb(0x5c99d3u, 0x1f4e79u, (t - 0.66f) / 0.34f);
}

static void finite_fault_draw_x_marker(unsigned char *rgb, uint32_t w, uint32_t h,
                                       int cx, int cy, int arm, uint32_t color) {
    rgb24_draw_line(rgb, w, h, cx - arm, cy - arm, cx + arm, cy + arm, color);
    rgb24_draw_line(rgb, w, h, cx - arm, cy + arm, cx + arm, cy - arm, color);
    rgb24_draw_line(rgb, w, h, cx - arm - 1, cy - arm, cx + arm - 1, cy + arm, color);
    rgb24_draw_line(rgb, w, h, cx - arm - 1, cy + arm, cx + arm - 1, cy - arm, color);
}

static float finite_fault_export_ocean_depth_m(int gx, int gy) {
    if (gx < 0 || gy < 0 || gx >= (int)g_grid.width || gy >= (int)g_grid.height) return 0.0f;
    size_t idx = tsunami_index(&g_grid, (uint32_t)gx, (uint32_t)gy);
    float d = 0.0f;
    if (g_grid.effective_depth_m) d = g_grid.effective_depth_m[idx];
    else if (g_grid.depth_m) d = g_grid.depth_m[idx];
    return clampf_gui(d, 0.0f, 11000.0f);
}

static void finite_fault_draw_map_panel(unsigned char *rgb, uint32_t w, uint32_t h,
                                        int x0, int y0, int x1, int y1,
                                        FaultExportPatchAccum *patches, int patch_count,
                                        float minx, float maxx, float miny, float maxy,
                                        float epic_x, float epic_y, float max_slip) {
    rgb24_fill_rect(rgb, w, h, x0, y0, x1, y1, 0xffffffu);
    rgb24_draw_rect_outline(rgb, w, h, x0, y0, x1, y1, 0x202020u);
    float dx = fmaxf(1.0f, maxx - minx);
    float dy = fmaxf(1.0f, maxy - miny);
    float sx = (float)(x1 - x0 - 2) / dx;
    float sy = (float)(y1 - y0 - 2) / dy;
    for (int py = y0 + 1; py < y1; ++py) {
        float gy = maxy - ((float)(py - (y0 + 1)) + 0.5f) / sy;
        int igy = (int)floorf(gy + 0.5f);
        for (int px = x0 + 1; px < x1; ++px) {
            float gx = minx + ((float)(px - (x0 + 1)) + 0.5f) / sx;
            int igx = (int)floorf(gx + 0.5f);
            uint32_t col = 0xffffffu;
            if (igx >= 0 && igy >= 0 && igx < (int)g_grid.width && igy < (int)g_grid.height) {
                size_t idx = tsunami_index(&g_grid, (uint32_t)igx, (uint32_t)igy);
                if (display_cell_is_land(idx)) col = 0xe9e1cfu;
                else col = finite_fault_map_water_rgb(finite_fault_export_ocean_depth_m(igx, igy));
            }
            rgb24_put_pixel(rgb, w, h, px, py, col);
        }
    }
    /* Coastline emphasis */
    for (int py = y0 + 2; py < y1 - 1; ++py) {
        float gy = maxy - ((float)(py - (y0 + 1)) + 0.5f) / sy;
        int igy = (int)floorf(gy + 0.5f);
        for (int px = x0 + 2; px < x1 - 1; ++px) {
            float gx = minx + ((float)(px - (x0 + 1)) + 0.5f) / sx;
            int igx = (int)floorf(gx + 0.5f);
            if (igx < 1 || igy < 1 || igx >= (int)g_grid.width - 1 || igy >= (int)g_grid.height - 1) continue;
            size_t idx = tsunami_index(&g_grid, (uint32_t)igx, (uint32_t)igy);
            int land = display_cell_is_land(idx);
            int edge = 0;
            for (int oy = -1; oy <= 1 && !edge; ++oy) {
                for (int ox = -1; ox <= 1; ++ox) {
                    if (!ox && !oy) continue;
                    size_t nidx = tsunami_index(&g_grid, (uint32_t)(igx + ox), (uint32_t)(igy + oy));
                    if (display_cell_is_land(nidx) != land) { edge = 1; break; }
                }
            }
            if (edge) rgb24_put_pixel(rgb, w, h, px, py, 0x6d5a3au);
        }
    }
    /* Smoothed finite-fault overlay on the map, instead of raw box patches. */
    for (int py = y0 + 1; py < y1; ++py) {
        float gy = maxy - ((float)(py - (y0 + 1)) + 0.5f) / sy;
        for (int px = x0 + 1; px < x1; ++px) {
            float gx = minx + ((float)(px - (x0 + 1)) + 0.5f) / sx;
            float sumw = 0.0f, sumv = 0.0f, maxlocal = 0.0f;
            for (int i = 0; i < patch_count; ++i) {
                FaultExportPatchAccum *pp = &patches[i];
                float cx = pp->sum_x / (float)pp->count;
                float cy = pp->sum_y / (float)pp->count;
                float slip = pp->sum_slip / (float)pp->count;
                float km_per_cell = local_ground_km_per_cell_mercator(cy);
                if (km_per_cell < 0.01f) km_per_cell = 0.01f;
                float length_cells = fmaxf(1.6f, pp->length_km / km_per_cell);
                float width_cells = fmaxf(1.4f, (pp->width_km * fault_dip_cos_deg(pp->dip_deg)) / km_per_cell);
                float rad = normalize_deg_gui(pp->strike_deg) * (3.14159265358979323846f / 180.0f);
                float sxv = sinf(rad), syv = cosf(rad), nxv = cosf(rad), nyv = -sinf(rad);
                float ddx = gx - cx;
                float ddy = gy - cy;
                float along = ddx * sxv + ddy * syv;
                float across = ddx * nxv + ddy * nyv;
                float sig_s = fmaxf(1.8f, 0.48f * length_cells);
                float sig_n = fmaxf(1.6f, 0.58f * width_cells);
                float ds = along / sig_s;
                float dn = across / sig_n;
                float rr = ds * ds + dn * dn;
                if (rr > 16.0f) continue;
                float wgt = expf(-0.5f * rr);
                if (fabsf(along) <= 0.5f * length_cells && fabsf(across) <= 0.5f * width_cells) wgt *= 1.8f;
                if (wgt > maxlocal) maxlocal = wgt;
                sumw += wgt;
                sumv += wgt * slip;
            }
            if (sumw > 0.10f && maxlocal > 0.035f) {
                float slip = sumv / sumw;
                uint32_t fill = finite_fault_usgs_slip_rgb(clampf_gui(slip / max_slip, 0.0f, 1.0f));
                float alpha = clampf_gui(0.25f + 0.50f * maxlocal, 0.25f, 0.82f);
                rgb24_blend_pixel(rgb, w, h, px, py, fill, alpha);
            }
        }
    }
    /* Epicenter marker */
    if (epic_x > -1.0e20f && epic_y > -1.0e20f) {
        int ex = (int)lroundf((float)(x0 + 1) + (epic_x - minx) * sx);
        int ey = (int)lroundf((float)(y0 + 1) + (maxy - epic_y) * sy);
        finite_fault_draw_x_marker(rgb, w, h, ex, ey, 8, 0xff00d0u);
    }
    rgb24_draw_text_gdi(rgb, w, h, x0 + 12, y0 + 10, "Map view", 24, 1, 0x111111u);
}

static void finite_fault_draw_faultplane_panel(unsigned char *rgb, uint32_t w, uint32_t h,
                                               int x0, int y0, int x1, int y1,
                                               FaultExportPatchAccum *patches, int patch_count,
                                               float max_slip) {
    rgb24_fill_rect(rgb, w, h, x0, y0, x1, y1, 0xffffffu);
    rgb24_draw_rect_outline(rgb, w, h, x0, y0, x1, y1, 0x202020u);
    if (patch_count <= 0) return;

    float sumsin = 0.0f, sumcos = 0.0f;
    float ref_x = 0.0f, ref_y = 0.0f;
    for (int i = 0; i < patch_count; ++i) {
        float rad = normalize_deg_gui(patches[i].strike_deg) * (3.14159265358979323846f / 180.0f);
        sumsin += sinf(rad);
        sumcos += cosf(rad);
        ref_x += patches[i].sum_x / (float)patches[i].count;
        ref_y += patches[i].sum_y / (float)patches[i].count;
    }
    ref_x /= (float)patch_count;
    ref_y /= (float)patch_count;
    float strike_rad = atan2f(sumsin, sumcos);
    float sxv = sinf(strike_rad);
    float syv = cosf(strike_rad);
    float nxv = cosf(strike_rad);
    float nyv = -sinf(strike_rad);
    float avg_km_cell = local_ground_km_per_cell_mercator(ref_y);
    if (avg_km_cell < 0.01f) avg_km_cell = 1.0f;

    float *pc_s = (float *)calloc((size_t)patch_count, sizeof(float));
    float *pc_d = (float *)calloc((size_t)patch_count, sizeof(float));
    float *plen = (float *)calloc((size_t)patch_count, sizeof(float));
    float *pwid = (float *)calloc((size_t)patch_count, sizeof(float));
    float *pslip = (float *)calloc((size_t)patch_count, sizeof(float));
    float *pdepth = (float *)calloc((size_t)patch_count, sizeof(float));
    if (!pc_s || !pc_d || !plen || !pwid || !pslip || !pdepth) {
        free(pc_s); free(pc_d); free(plen); free(pwid); free(pslip); free(pdepth);
        return;
    }

    /* First build strike / normal coordinates.  Then orient the normal axis by
       the stored finite-fault depths so the top of the panel is shallow/up-dip
       and the bottom is deeper/down-dip. */
    float mean_d = 0.0f;
    float mean_depth = 0.0f;
    for (int i = 0; i < patch_count; ++i) {
        FaultExportPatchAccum *pp = &patches[i];
        float cx = pp->sum_x / (float)pp->count;
        float cy = pp->sum_y / (float)pp->count;
        float dx = (cx - ref_x) * avg_km_cell;
        float dy = (cy - ref_y) * avg_km_cell;
        float s = dx * sxv + dy * syv;
        float d = dx * nxv + dy * nyv;
        float length_km = fmaxf(1.0f, pp->length_km);
        float width_km = fmaxf(1.0f, pp->width_km);
        float slip = pp->sum_slip / (float)pp->count;
        pc_s[i] = s;
        pc_d[i] = d;
        plen[i] = length_km;
        pwid[i] = width_km;
        pslip[i] = slip;
        pdepth[i] = pp->depth_km;
        mean_d += d;
        mean_depth += pp->depth_km;
    }
    mean_d /= (float)patch_count;
    mean_depth /= (float)patch_count;
    float cov_d_depth = 0.0f;
    for (int i = 0; i < patch_count; ++i) {
        cov_d_depth += (pc_d[i] - mean_d) * (pdepth[i] - mean_depth);
    }
    if (cov_d_depth < 0.0f) {
        for (int i = 0; i < patch_count; ++i) pc_d[i] = -pc_d[i];
    }

    float mins = 1.0e30f, maxs = -1.0e30f, mind = 1.0e30f, maxd = -1.0e30f;
    for (int i = 0; i < patch_count; ++i) {
        if (pc_s[i] - 0.5f * plen[i] < mins) mins = pc_s[i] - 0.5f * plen[i];
        if (pc_s[i] + 0.5f * plen[i] > maxs) maxs = pc_s[i] + 0.5f * plen[i];
        if (pc_d[i] - 0.5f * pwid[i] < mind) mind = pc_d[i] - 0.5f * pwid[i];
        if (pc_d[i] + 0.5f * pwid[i] > maxd) maxd = pc_d[i] + 0.5f * pwid[i];
    }
    float margins = fmaxf(10.0f, 0.10f * (maxs - mins));
    float margind = fmaxf(8.0f, 0.10f * (maxd - mind));
    mins -= margins; maxs += margins; mind -= margind; maxd += margind;
    float ds_range = fmaxf(1.0f, maxs - mins);
    float dd_range = fmaxf(1.0f, maxd - mind);
    float scale_x = (float)(x1 - x0 - 20) / ds_range;
    float scale_y = (float)(y1 - y0 - 20) / dd_range;
    float scale = fminf(scale_x, scale_y);
    float ox = (float)x0 + 10.0f + 0.5f * ((float)(x1 - x0 - 20) - scale * ds_range);
    float oy = (float)y0 + 10.0f + 0.5f * ((float)(y1 - y0 - 20) - scale * dd_range);

    /* Smoothed/interpolated slip field to avoid patch-to-patch holes. */
    for (int py = y0 + 1; py < y1; ++py) {
        float d = mind + ((float)(py - (y0 + 1)) + 0.5f) / scale;
        for (int px = x0 + 1; px < x1; ++px) {
            float s = mins + ((float)(px - (x0 + 1)) + 0.5f) / scale;
            float sumw = 0.0f, sumv = 0.0f, maxlocal = 0.0f;
            for (int i = 0; i < patch_count; ++i) {
                float sig_s = fmaxf(2.8f, 0.48f * plen[i]);
                float sig_d = fmaxf(2.3f, 0.48f * pwid[i]);
                float ds = (s - pc_s[i]) / sig_s;
                float dd = (d - pc_d[i]) / sig_d;
                float rr = ds * ds + dd * dd;
                if (rr > 16.0f) continue;
                float wgt = expf(-0.5f * rr);
                if (fabsf(s - pc_s[i]) <= 0.5f * plen[i] && fabsf(d - pc_d[i]) <= 0.5f * pwid[i]) wgt *= 1.9f;
                if (wgt > maxlocal) maxlocal = wgt;
                sumw += wgt;
                sumv += wgt * pslip[i];
            }
            if (sumw > 0.10f && maxlocal > 0.035f) {
                float slip = sumv / sumw;
                uint32_t fill = finite_fault_usgs_slip_rgb(clampf_gui(slip / max_slip, 0.0f, 1.0f));
                rgb24_put_pixel(rgb, w, h, px, py, fill);
            }
        }
    }
    rgb24_draw_text_gdi(rgb, w, h, x0 + 12, y0 + 10, "Interpolated fault-plane view", 24, 1, 0x111111u);
    rgb24_draw_text_gdi(rgb, w, h, x0 + 14, y1 - 26, "Along strike", 18, 0, 0x303030u);
    rgb24_draw_text_gdi(rgb, w, h, x1 - 150, y0 + 16, "Shallow / up-dip", 18, 0, 0x303030u);
    rgb24_draw_text_gdi(rgb, w, h, x1 - 142, y1 - 28, "Deep / down-dip", 18, 0, 0x303030u);
    int ax = x1 - 28;
    int ay0 = y0 + 48;
    int ay1 = y1 - 48;
    rgb24_draw_line(rgb, w, h, ax, ay0, ax, ay1, 0x303030u);
    rgb24_draw_line(rgb, w, h, ax, ay1, ax - 7, ay1 - 13, 0x303030u);
    rgb24_draw_line(rgb, w, h, ax, ay1, ax + 7, ay1 - 13, 0x303030u);

    free(pc_s); free(pc_d); free(plen); free(pwid); free(pslip); free(pdepth);
}

static int write_finite_fault_usgs_png(const char *path) {
    enum { W = 1700, H = 980 };
    const int map_x0 = 60, map_y0 = 92, map_x1 = 770, map_y1 = 850;
    const int fp_x0 = 820, fp_y0 = 92, fp_x1 = 1460, fp_y1 = 850;
    const int cbar_x0 = 1510, cbar_x1 = 1565, cbar_y0 = 150, cbar_y1 = 780;
    unsigned char *rgb = (unsigned char *)malloc((size_t)W * (size_t)H * 3u);
    if (!rgb) return 0;

    FaultExportPatchAccum *patches = NULL;
    int patch_count = 0;
    float max_slip = 0.0f;
    if (!collect_finite_fault_export_patches(&patches, &patch_count, &max_slip) || patch_count <= 0) {
        free(rgb);
        return 0;
    }
    if (max_slip < 0.01f) max_slip = 0.01f;

    rgb24_fill(rgb, W, H, 0xffffffu);
    rgb24_fill_rect(rgb, W, H, 0, 0, W - 1, 74, 0xf7f7f7u);
    rgb24_draw_text_gdi(rgb, W, H, 60, 20, "USGS-STYLE FINITE FAULT MODEL", 34, 1, 0x111111u);
    rgb24_draw_text_gdi(rgb, W, H, 62, 55, "Smoothed finite-fault export from direct stamp patches | smoothed map overlay + interpolated fault-plane view", 20, 0, 0x404040u);

    float minx = 1.0e30f, miny = 1.0e30f, maxx = -1.0e30f, maxy = -1.0e30f;
    float epic_x = 0.0f, epic_y = 0.0f; int epic_count = 0; int min_delay = INT_MAX;
    for (int i = 0; i < patch_count; ++i) {
        FaultExportPatchAccum *pp = &patches[i];
        float cx = pp->sum_x / (float)pp->count;
        float cy = pp->sum_y / (float)pp->count;
        float km_per_cell = local_ground_km_per_cell_mercator(cy);
        if (km_per_cell < 0.01f) km_per_cell = 0.01f;
        float length_cells = fmaxf(1.6f, pp->length_km / km_per_cell);
        float width_cells = fmaxf(1.4f, (pp->width_km * fault_dip_cos_deg(pp->dip_deg)) / km_per_cell);
        float rad = normalize_deg_gui(pp->strike_deg) * (3.14159265358979323846f / 180.0f);
        float sx = sinf(rad), sy = cosf(rad), nx = cosf(rad), ny = -sinf(rad);
        float rx = fabsf(sx) * 0.5f * length_cells + fabsf(nx) * 0.5f * width_cells;
        float ry = fabsf(sy) * 0.5f * length_cells + fabsf(ny) * 0.5f * width_cells;
        if (cx - rx < minx) minx = cx - rx;
        if (cx + rx > maxx) maxx = cx + rx;
        if (cy - ry < miny) miny = cy - ry;
        if (cy + ry > maxy) maxy = cy + ry;
        if (pp->delay_min < min_delay) {
            min_delay = pp->delay_min; epic_x = cx; epic_y = cy; epic_count = 1;
        } else if (pp->delay_min == min_delay) {
            epic_x += cx; epic_y += cy; epic_count++;
        }
    }
    if (epic_count > 1) { epic_x /= (float)epic_count; epic_y /= (float)epic_count; }
    if (!(minx < maxx)) { minx = 0.0f; maxx = 1.0f; }
    if (!(miny < maxy)) { miny = 0.0f; maxy = 1.0f; }
    float mx = maxx - minx, my = maxy - miny;
    float margin_x = fmaxf(40.0f, 0.30f * mx);
    float margin_y = fmaxf(40.0f, 0.30f * my);
    minx -= margin_x; maxx += margin_x; miny -= margin_y; maxy += margin_y;

    finite_fault_draw_map_panel(rgb, W, H, map_x0, map_y0, map_x1, map_y1,
                                patches, patch_count, minx, maxx, miny, maxy,
                                epic_x, epic_y, max_slip);
    finite_fault_draw_faultplane_panel(rgb, W, H, fp_x0, fp_y0, fp_x1, fp_y1,
                                       patches, patch_count, max_slip);

    rgb24_fill_rect(rgb, W, H, cbar_x0, cbar_y0, cbar_x1, cbar_y1, 0xffffffu);
    rgb24_draw_rect_outline(rgb, W, H, cbar_x0, cbar_y0, cbar_x1, cbar_y1, 0x202020u);
    for (int y = cbar_y0 + 1; y < cbar_y1; ++y) {
        float t = 1.0f - ((float)(y - (cbar_y0 + 1)) / (float)fmaxf(1.0f, (float)(cbar_y1 - cbar_y0 - 2)));
        uint32_t col = finite_fault_usgs_slip_rgb(t);
        for (int x = cbar_x0 + 1; x < cbar_x1; ++x) rgb24_put_pixel(rgb, W, H, x, y, col);
    }
    rgb24_draw_text_gdi(rgb, W, H, cbar_x0 - 4, 114, "Slip (m)", 22, 1, 0x202020u);
    for (int k = 0; k <= 5; ++k) {
        int y = cbar_y1 - (int)lroundf((float)k * (float)(cbar_y1 - cbar_y0) / 5.0f);
        rgb24_fill_rect(rgb, W, H, cbar_x1 + 4, y - 1, cbar_x1 + 16, y + 1, 0x303030u);
        char tick[64];
        snprintf(tick, sizeof(tick), "%.1f", max_slip * (float)k / 5.0f);
        rgb24_draw_text_gdi(rgb, W, H, cbar_x1 + 22, y - 9, tick, 18, 0, 0x202020u);
    }

    char info1[256], info2[256], info3[256];
    snprintf(info1, sizeof(info1), "Patches: %d", patch_count);
    snprintf(info2, sizeof(info2), "Maximum slip: %.2f m", max_slip);
    snprintf(info3, sizeof(info3), "Mw: %.2f  |  X: Epicenter = minimum rupture-delay patch centroid", current_moment_magnitude());
    rgb24_draw_text_gdi(rgb, W, H, 1510, 820, info1, 20, 1, 0x202020u);
    rgb24_draw_text_gdi(rgb, W, H, 1510, 848, info2, 20, 1, 0x202020u);
    rgb24_draw_text_gdi(rgb, W, H, 1510, 876, "X: Epicenter", 20, 1, 0x7a004du);
    rgb24_draw_text_gdi(rgb, W, H, 60, 900, info3, 20, 0, 0x202020u);
    rgb24_draw_text_gdi(rgb, W, H, 60, 928, "Both panels use Gaussian/interpolated slip smoothing to bridge unnatural patch-to-patch gaps. In the fault-plane panel, the vertical axis is depth-oriented: top = shallow/up-dip, bottom = deep/down-dip.", 18, 0, 0x404040u);

    int ok = write_png_rgb24_uncompressed(path, rgb, W, H);
    free(patches);
    free(rgb);
    return ok;
}

static void save_finite_fault_usgs_png_dialog(HWND hwnd) {
    char file_name[MAX_PATH] = "finite_fault_usgs_style_map_faultplane.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 finite-fault USGS-style map + fault-plane PNG";
    if (GetSaveFileNameA(&ofn)) {
        if (write_finite_fault_usgs_png(file_name)) {
            set_status("Saved finite-fault USGS-style PNG: %s", file_name);
        } else {
            set_status("Failed to save finite-fault USGS-style PNG. Make sure direct finite-fault patches exist.");
        }
        InvalidateRect(hwnd, NULL, FALSE);
    }
}

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));
    int recalculated_from_slip = 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;
        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 (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);

                    if (n >= 5 && isfinite(slip) && fabsf(slip) > SOURCE_SLIP_HEIGHT_EPS_M &&
                        !(n >= 9 && patch_id < 0)) {
                        float use_dip = last->stamp_dip_deg;
                        if (!isfinite(use_dip) || use_dip < 5.0f || use_dip > 60.0f) {
                            use_dip = source_stamp_dip_now_deg();
                        }
                        float use_depth = last->stamp_depth_km;
                        if (!isfinite(use_depth)) use_depth = g_source_depth_km;
                        float rebuilt_h = preset_recompute_height_from_slip_depth(slip, use_dip, use_depth);
                        last->height_m = rebuilt_h;
                        last->slip_m = sanitize_source_slip_for_height(rebuilt_h, slip);
                        recalculated_from_slip++;
                    } else 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);
                    }
                }
            }
        } 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;
        }
    }

    g_last_preset_recalculated_from_slip = recalculated_from_slip;
    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); rebuilt %d from slip+depth.",
                       loaded, g_last_preset_recalculated_from_slip);
        } 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_float_any(HWND hwnd, int id, 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;
    *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, 100, 28, ID_SET_TAB_MAIN);
    make_child(hwnd, "BUTTON", active_tab == 1 ? "[Colors]" : "Colors", BS_PUSHBUTTON, x + 106, y, 100, 28, ID_SET_TAB_COLORS);
    make_child(hwnd, "BUTTON", active_tab == 2 ? "[Preset]" : "Preset", BS_PUSHBUTTON, x + 212, y, 100, 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 int petal_slider_pos(float value) {
    if (value < 0.0f) value = 0.0f;
    if (value > 6.0f) value = 6.0f;
    return (int)(value * 100.0f + 0.5f);
}

static float petal_slider_value(int pos) {
    if (pos < 0) pos = 0;
    if (pos > 600) pos = 600;
    return (float)pos / 100.0f;
}

static void settings_set_petal_slider(HWND hwnd, int id, float value, float min_v, float max_v) {
    HWND h = GetDlgItem(hwnd, id);
    if (!h) return;
    int min_p = petal_slider_pos(min_v);
    int max_p = petal_slider_pos(max_v);
    int pos = petal_slider_pos(value);
    if (pos < min_p) pos = min_p;
    if (pos > max_p) pos = max_p;
    SetScrollRange(h, SB_CTL, min_p, max_p, FALSE);
    SetScrollPos(h, SB_CTL, pos, TRUE);
}

static HWND settings_make_petal_slider(HWND hwnd, int x, int y, const char *title,
                                      int slider_id, int label_id, int edit_id,
                                      float value, float min_v, float max_v) {
    make_child(hwnd, "STATIC", title, 0, x, y + 3, 185, 20, -1);
    HWND h = make_child(hwnd, "SCROLLBAR", "", SBS_HORZ, x + 190, y, 270, 22, slider_id);
    make_child(hwnd, "STATIC", "", 0, x + 470, y + 3, 80, 20, label_id);
    make_child(hwnd, "EDIT", "", WS_BORDER | ES_AUTOHSCROLL, x + 555, y - 1, 95, 24, edit_id);
    settings_set_petal_slider(hwnd, slider_id, value, min_v, max_v);
    return h;
}

static void settings_create_petal_page(HWND hwnd) {
    int x = 14, y = 12;
    settings_create_tab_buttons(hwnd, 3);
    y += 42;

    make_child(hwnd, "STATIC", "Secondary deformation disabled", 0, x, y, 660, 22, -1); y += 26;
    make_child(hwnd, "STATIC", "Auto secondary generation is disabled in v78. These old petal controls are kept only for compatibility and have no effect unless re-enabled in code.", 0, x, y, 700, 20, -1); y += 34;

    settings_make_petal_slider(hwnd, x, y, "Main outward axis", ID_SET_PETAL_MAIN_OUT, ID_SET_LBL_PETAL_MAIN_OUT, ID_SET_EDIT_PETAL_MAIN_OUT,
                               g_clover_main_out_axis, 0.00f, 200.00f); y += 38;
    settings_make_petal_slider(hwnd, x, y, "Main strike full axis", ID_SET_PETAL_MAIN_STRIKE, ID_SET_LBL_PETAL_MAIN_STRIKE, ID_SET_EDIT_PETAL_MAIN_STRIKE,
                               g_clover_main_strike_full, 0.00f, 200.00f); y += 38;
    settings_make_petal_slider(hwnd, x, y, "Opposite petal length", ID_SET_PETAL_OPPOSITE_LEN, ID_SET_LBL_PETAL_OPPOSITE_LEN, ID_SET_EDIT_PETAL_OPPOSITE_LEN,
                               g_clover_opposite_len, 0.00f, 200.00f); y += 38;
    settings_make_petal_slider(hwnd, x, y, "End outward offset", ID_SET_PETAL_END_OUT, ID_SET_LBL_PETAL_END_OUT, ID_SET_EDIT_PETAL_END_OUT,
                               g_clover_end_out_offset, 0.00f, 200.00f); y += 38;
    settings_make_petal_slider(hwnd, x, y, "End strike length", ID_SET_PETAL_END_STRIKE, ID_SET_LBL_PETAL_END_STRIKE, ID_SET_EDIT_PETAL_END_STRIKE,
                               g_clover_end_strike_len, 0.00f, 200.00f); y += 38;
    settings_make_petal_slider(hwnd, x, y, "Corner outward gap", ID_SET_PETAL_CORNER_TILT, ID_SET_LBL_PETAL_CORNER_TILT, ID_SET_EDIT_PETAL_CORNER_GAP,
                               g_clover_corner_tilt_deg, 0.00f, 200.00f); y += 46;

    make_child(hwnd, "BUTTON", "Apply typed values", BS_PUSHBUTTON, x, y, 155, 30, ID_SET_PETAL_APPLY);
    make_child(hwnd, "BUTTON", "Regenerate secondary now", BS_PUSHBUTTON, x + 165, y, 190, 30, ID_SET_PETAL_REGEN);
    make_child(hwnd, "BUTTON", "Reset petal defaults", BS_PUSHBUTTON, x + 365, y, 150, 30, ID_SET_PETAL_RESET);
    make_child(hwnd, "BUTTON", "Close", BS_PUSHBUTTON, x + 540, y, 90, 30, ID_SET_CLOSE); y += 44;

    make_child(hwnd, "STATIC", "", 0, x, y, 680, 80, ID_SET_LBL_PETAL_STATUS);
}

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());

    settings_set_petal_slider(g_settings_hwnd, ID_SET_PETAL_MAIN_OUT, g_clover_main_out_axis, 0.00f, 200.00f);
    settings_set_petal_slider(g_settings_hwnd, ID_SET_PETAL_MAIN_STRIKE, g_clover_main_strike_full, 0.00f, 200.00f);
    settings_set_petal_slider(g_settings_hwnd, ID_SET_PETAL_OPPOSITE_LEN, g_clover_opposite_len, 0.00f, 200.00f);
    settings_set_petal_slider(g_settings_hwnd, ID_SET_PETAL_END_OUT, g_clover_end_out_offset, 0.00f, 200.00f);
    settings_set_petal_slider(g_settings_hwnd, ID_SET_PETAL_END_STRIKE, g_clover_end_strike_len, 0.00f, 200.00f);
    settings_set_petal_slider(g_settings_hwnd, ID_SET_PETAL_CORNER_TILT, g_clover_corner_tilt_deg, 0.00f, 200.00f);
    set_child_textf(g_settings_hwnd, ID_SET_LBL_PETAL_MAIN_OUT, "%.2fx", g_clover_main_out_axis);
    set_child_textf(g_settings_hwnd, ID_SET_LBL_PETAL_MAIN_STRIKE, "%.2fx", g_clover_main_strike_full);
    set_child_textf(g_settings_hwnd, ID_SET_LBL_PETAL_OPPOSITE_LEN, "%.2fx", g_clover_opposite_len);
    set_child_textf(g_settings_hwnd, ID_SET_LBL_PETAL_END_OUT, "%.2fx", g_clover_end_out_offset);
    float corner_gap = g_clover_corner_tilt_deg;
    set_child_textf(g_settings_hwnd, ID_SET_LBL_PETAL_END_STRIKE, "%.2fx", g_clover_end_strike_len);
    set_child_textf(g_settings_hwnd, ID_SET_LBL_PETAL_CORNER_TILT, "%.2fx", corner_gap);
    set_child_textf(g_settings_hwnd, ID_SET_LBL_PETAL_STATUS,
                    "Petal params: main outward %.2fx, main strike %.2fx, opposite %.2fx, end outward %.2fx, end strike %.2fx, corner outward gap %.2fx. In the 4-ellipse model, increasing corner gap moves all 4 ellipse centers farther outward. If ellipses look like half-shapes stuck to the fault, increase corner gap and/or end outward offset.",
                    g_clover_main_out_axis, g_clover_main_strike_full, g_clover_opposite_len,
                    g_clover_end_out_offset, g_clover_end_strike_len, corner_gap);
    set_edit_textf_if_not_focused(g_settings_hwnd, ID_SET_EDIT_PETAL_MAIN_OUT, "%.6g", g_clover_main_out_axis);
    set_edit_textf_if_not_focused(g_settings_hwnd, ID_SET_EDIT_PETAL_MAIN_STRIKE, "%.6g", g_clover_main_strike_full);
    set_edit_textf_if_not_focused(g_settings_hwnd, ID_SET_EDIT_PETAL_OPPOSITE_LEN, "%.6g", g_clover_opposite_len);
    set_edit_textf_if_not_focused(g_settings_hwnd, ID_SET_EDIT_PETAL_END_OUT, "%.6g", g_clover_end_out_offset);
    set_edit_textf_if_not_focused(g_settings_hwnd, ID_SET_EDIT_PETAL_END_STRIKE, "%.6g", g_clover_end_strike_len);
    set_edit_textf_if_not_focused(g_settings_hwnd, ID_SET_EDIT_PETAL_CORNER_GAP, "%.6g", g_clover_corner_tilt_deg);
}

static int apply_petal_values_from_edits(HWND hwnd, int show_message_on_success) {
    float v_main_out = 0.0f, v_main_strike = 0.0f, v_opp = 0.0f;
    float v_end_out = 0.0f, v_end_strike = 0.0f, v_corner_gap = 0.0f;
    if (!read_edit_float_any(hwnd, ID_SET_EDIT_PETAL_MAIN_OUT, &v_main_out) ||
        !read_edit_float_any(hwnd, ID_SET_EDIT_PETAL_MAIN_STRIKE, &v_main_strike) ||
        !read_edit_float_any(hwnd, ID_SET_EDIT_PETAL_OPPOSITE_LEN, &v_opp) ||
        !read_edit_float_any(hwnd, ID_SET_EDIT_PETAL_END_OUT, &v_end_out) ||
        !read_edit_float_any(hwnd, ID_SET_EDIT_PETAL_END_STRIKE, &v_end_strike) ||
        !read_edit_float_any(hwnd, ID_SET_EDIT_PETAL_CORNER_GAP, &v_corner_gap)) {
        MessageBoxA(hwnd, "All petal direct-input boxes must contain valid numbers. Typed petal values are otherwise unrestricted.", "Invalid petal input", MB_OK | MB_ICONWARNING);
        return 0;
    }
    g_clover_main_out_axis = v_main_out;
    g_clover_main_strike_full = v_main_strike;
    g_clover_opposite_len = v_opp;
    g_clover_end_out_offset = v_end_out;
    g_clover_end_strike_len = v_end_strike;
    g_clover_corner_tilt_deg = v_corner_gap;
    if (show_message_on_success) {
        settings_changed("Applied typed petal values (unclamped). Press Regenerate or G to rebuild secondary deformation.");
    } else {
        settings_refresh();
        refresh_main_window();
    }
    return 1;
}

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_TAB_PETAL:
    case ID_SET_PETAL_RESET:
    case ID_SET_PETAL_APPLY:
    case ID_SET_PETAL_REGEN:
        settings_changed("Petal settings were removed; automatic secondary deformation remains disabled.");
        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 int settings_petal_slider_info(int id, float **value, float *min_v, float *max_v, const char **name) {
    switch (id) {
    case ID_SET_PETAL_MAIN_OUT:
        *value = &g_clover_main_out_axis; *min_v = 0.00f; *max_v = 200.00f; *name = "Main outward axis"; return 1;
    case ID_SET_PETAL_MAIN_STRIKE:
        *value = &g_clover_main_strike_full; *min_v = 0.00f; *max_v = 200.00f; *name = "Main strike full axis"; return 1;
    case ID_SET_PETAL_OPPOSITE_LEN:
        *value = &g_clover_opposite_len; *min_v = 0.00f; *max_v = 200.00f; *name = "Opposite petal length"; return 1;
    case ID_SET_PETAL_END_OUT:
        *value = &g_clover_end_out_offset; *min_v = 0.00f; *max_v = 200.00f; *name = "End outward offset"; return 1;
    case ID_SET_PETAL_END_STRIKE:
        *value = &g_clover_end_strike_len; *min_v = 0.00f; *max_v = 200.00f; *name = "End strike length"; return 1;
    case ID_SET_PETAL_CORNER_TILT:
        *value = &g_clover_corner_tilt_deg; *min_v = 0.00f; *max_v = 200.00f; *name = "Corner outward gap"; return 1;
    default:
        return 0;
    }
}


static int quick_ff_edit_id(int view_row, int view_col) { return ID_QUICK_FF_EDIT_BASE + view_row * QUICK_FF_VIEW_COLS + view_col; }
static int quick_ff_row_from_id(int id) { return (id - ID_QUICK_FF_EDIT_BASE) / QUICK_FF_VIEW_COLS; }
static int quick_ff_col_from_id(int id) { return (id - ID_QUICK_FF_EDIT_BASE) % QUICK_FF_VIEW_COLS; }
static int quick_ff_actual_row_from_view(int view_row) { return g_quick_ff_view_row + view_row; }
static int quick_ff_actual_col_from_view(int view_col) { return g_quick_ff_view_col + view_col; }
static void quick_ff_clamp_view_origin(void) {
    int max_view_row = g_quick_ff_grid_rows - QUICK_FF_VIEW_ROWS;
    int max_view_col = g_quick_ff_grid_cols - QUICK_FF_VIEW_COLS;
    if (max_view_row < 0) max_view_row = 0;
    if (max_view_col < 0) max_view_col = 0;
    g_quick_ff_view_row = clampi_gui(g_quick_ff_view_row, 0, max_view_row);
    g_quick_ff_view_col = clampi_gui(g_quick_ff_view_col, 0, max_view_col);
}
static int quick_ff_index_with_cols(int row, int col, int cols) { return row * cols + col; }
static int quick_ff_index(int row, int col) { return quick_ff_index_with_cols(row, col, g_quick_ff_grid_cols); }
static const char *quick_ff_col_name(int col) {
    static char names[QUICK_FF_GRID_COLS][6];
    if (col < 0 || col >= QUICK_FF_GRID_COLS) return "?";
    if (!names[col][0]) {
        int n = col + 1;
        char tmp[6];
        int p = 0;
        while (n > 0 && p < 5) {
            int r = (n - 1) % 26;
            tmp[p++] = (char)('A' + r);
            n = (n - 1) / 26;
        }
        for (int i = 0; i < p; ++i) names[col][i] = tmp[p - 1 - i];
        names[col][p] = '\0';
    }
    return names[col];
}
static void quick_ff_cell_label(int row, int col, char *out, size_t out_sz) {
    snprintf(out, out_sz, "%s%d", quick_ff_col_name(col), row + 1);
}

static float quick_ff_max_slip(void) {
    float m = 0.0f;
    for (int i = 0; i < g_quick_ff_count; ++i) if (g_quick_ff_rows[i].enabled && g_quick_ff_rows[i].slip_m > m) m = g_quick_ff_rows[i].slip_m;
    return fmaxf(m, 0.001f);
}
static int quick_ff_max_delay(void) {
    int m = 1;
    for (int i = 0; i < g_quick_ff_count; ++i) if (g_quick_ff_rows[i].enabled && g_quick_ff_rows[i].delay_s > m) m = g_quick_ff_rows[i].delay_s;
    return m;
}
static COLORREF quick_ff_hsv_color(float ratio) {
    ratio = clampf_gui(ratio, 0.0f, 1.0f);
    float hue;
    float sat = 1.0f;
    if (ratio <= 0.20f) { hue = 180.0f; sat = ratio / 0.20f; }
    else if (ratio <= 0.40f) { hue = 180.0f - (ratio - 0.20f) * (60.0f / 0.20f); }
    else if (ratio <= 0.60f) { hue = 120.0f - (ratio - 0.40f) * (70.0f / 0.20f); }
    else { hue = 50.0f - (ratio - 0.60f) * (50.0f / 0.40f); }
    float c = sat;
    float hp = hue / 60.0f;
    float x = c * (1.0f - fabsf(fmodf(hp, 2.0f) - 1.0f));
    float r = 0.0f, g = 0.0f, b = 0.0f;
    if (hp < 1.0f) { r = c; g = x; }
    else if (hp < 2.0f) { r = x; g = c; }
    else if (hp < 3.0f) { g = c; b = x; }
    else if (hp < 4.0f) { g = x; b = c; }
    else if (hp < 5.0f) { r = x; b = c; }
    else { r = c; b = x; }
    /* HSV value=1; white comes from low saturation. */
    float m = 1.0f - c;
    int R = clampi_gui((int)lroundf((r + m) * 255.0f), 0, 255);
    int G = clampi_gui((int)lroundf((g + m) * 255.0f), 0, 255);
    int B = clampi_gui((int)lroundf((b + m) * 255.0f), 0, 255);
    return RGB(R, G, B);
}
static COLORREF quick_ff_cell_color(int row, int col) {
    int idx = quick_ff_index(row, col);
    if (idx < 0 || idx >= g_quick_ff_count) return RGB(255,255,255);
    QuickFiniteFaultRow *r = &g_quick_ff_rows[idx];
    if (!r->enabled || r->slip_m <= 0.0f) return RGB(255,255,255);
    if (g_quick_ff_color_mode == 0) return quick_ff_hsv_color(r->slip_m / quick_ff_max_slip());
    return quick_ff_hsv_color((float)r->delay_s / (float)quick_ff_max_delay());
}


static int quick_ff_read_edits(HWND hwnd, int warn);
static void quick_ff_write_edits(HWND hwnd);
static void quick_ff_refresh_colors(HWND hwnd);
static int quick_ff_read_patch_size_controls(HWND hwnd, int warn);
static void quick_ff_update_mw_preview(HWND hwnd);

static float quick_ff_rel_d_from_row(int row) {
    return g_quick_ff_row1_is_shallow ? (float)(row - QUICK_FF_ORIGIN_ROW)
                                      : (float)(QUICK_FF_ORIGIN_ROW - row);
}

static void quick_ff_recompute_relative_grid(void) {
    for (int row = 0; row < g_quick_ff_grid_rows; ++row) {
        for (int col = 0; col < g_quick_ff_grid_cols; ++col) {
            int idx = quick_ff_index(row, col);
            if (idx < 0 || idx >= g_quick_ff_count) continue;
            g_quick_ff_rows[idx].rel_s = (float)(col - QUICK_FF_ORIGIN_COL);
            g_quick_ff_rows[idx].rel_d = quick_ff_rel_d_from_row(row);
        }
    }
}

static int quick_ff_find_focused_cell(HWND hwnd, int *out_row, int *out_col) {
    HWND focus = GetFocus();
    if (!focus) return 0;
    int id = GetDlgCtrlID(focus);
    int vr = quick_ff_row_from_id(id);
    int vc = quick_ff_col_from_id(id);
    if (vr < 0 || vr >= QUICK_FF_VIEW_ROWS || vc < 0 || vc >= QUICK_FF_VIEW_COLS) return 0;
    int row = quick_ff_actual_row_from_view(vr);
    int col = quick_ff_actual_col_from_view(vc);
    if (row < 0 || col < 0 || row >= g_quick_ff_grid_rows || col >= g_quick_ff_grid_cols) return 0;
    if (out_row) *out_row = row;
    if (out_col) *out_col = col;
    return 1;
}

static int quick_ff_read_slip_percent_control(HWND hwnd, int warn) {
    char b[64];
    GetDlgItemTextA(hwnd, ID_QUICK_FF_SLIP_PERCENT_EDIT, b, sizeof(b));
    if (!b[0]) return 1;
    char *end = NULL;
    float v = strtof(b, &end);
    while (end && (*end == ' ' || *end == '\t' || *end == '\r' || *end == '\n')) end++;
    if (end == b || (end && *end != '\0') || !isfinite(v) || v < 0.0f) {
        if (warn) MessageBoxA(hwnd, "Invalid slip percent. Use a non-negative number, e.g. 80 or 120.", "Finite fault editor", MB_OK | MB_ICONWARNING);
        return 0;
    }
    g_quick_ff_slip_percent = v;
    return 1;
}

static int quick_ff_read_bulk_dip_control(HWND hwnd, int warn) {
    char b[64];
    GetDlgItemTextA(hwnd, ID_QUICK_FF_DIP_EDIT, b, sizeof(b));
    if (!b[0]) {
        g_quick_ff_bulk_dip_deg = clampf_gui(g_fault_dip_deg, 5.0f, 60.0f);
        return 1;
    }
    char *end = NULL;
    float v = strtof(b, &end);
    while (end && (*end == ' ' || *end == '\t' || *end == '\r' || *end == '\n')) end++;
    if (end == b || (end && *end != '\0') || !isfinite(v) || v < 5.0f || v > 60.0f) {
        if (warn) MessageBoxA(hwnd, "Bulk dip must be from 5 to 60 degrees.", "Finite fault editor", MB_OK | MB_ICONWARNING);
        return 0;
    }
    g_quick_ff_bulk_dip_deg = clampf_gui(v, 5.0f, 60.0f);
    return 1;
}

static void quick_ff_apply_bulk_dip_to_active_cells(HWND hwnd) {
    if (!quick_ff_read_edits(hwnd, 1)) return;
    if (!quick_ff_read_bulk_dip_control(hwnd, 1)) return;
    int changed = 0;
    for (int i = 0; i < g_quick_ff_count; ++i) {
        if (!g_quick_ff_rows[i].enabled || g_quick_ff_rows[i].slip_m <= 0.0f) continue;
        g_quick_ff_rows[i].dip_deg = g_quick_ff_bulk_dip_deg;
        changed++;
    }
    quick_ff_write_edits(hwnd);
    quick_ff_refresh_colors(hwnd);
    set_status("Quick FF CSV editor: applied dip %.1f deg to %d active cell(s).", g_quick_ff_bulk_dip_deg, changed);
}

static void quick_ff_apply_slip_percent_from_focused_cell(HWND hwnd) {
    if (!quick_ff_read_edits(hwnd, 1)) return;
    if (!quick_ff_read_slip_percent_control(hwnd, 1)) return;
    int row = 0, col = 0;
    if (!quick_ff_find_focused_cell(hwnd, &row, &col)) {
        MessageBoxA(hwnd, "Click/focus one visible cell first. Its slip becomes the base value.", "Finite fault editor", MB_OK | MB_ICONINFORMATION);
        return;
    }
    int idx = quick_ff_index(row, col);
    if (idx < 0 || idx >= g_quick_ff_count || !g_quick_ff_rows[idx].enabled || g_quick_ff_rows[idx].slip_m <= 0.0f) {
        MessageBoxA(hwnd, "Focused cell has no positive slip value.", "Finite fault editor", MB_OK | MB_ICONINFORMATION);
        return;
    }
    float focused_slip = g_quick_ff_rows[idx].slip_m;
    float new_slip = focused_slip * (g_quick_ff_slip_percent / 100.0f);
    int changed = 0;
    for (int i = 0; i < g_quick_ff_count; ++i) {
        if (!g_quick_ff_rows[i].enabled) continue;
        g_quick_ff_rows[i].slip_m = new_slip;
        changed++;
    }
    quick_ff_write_edits(hwnd);
    quick_ff_refresh_colors(hwnd);
    char msg[160];
    snprintf(msg, sizeof(msg), "Set %d active quick-FF cells to focused slip %.3g m x %.1f%% = %.3g m.", changed, focused_slip, g_quick_ff_slip_percent, new_slip);
    set_status("%s", msg);
}

static void quick_ff_auto_delay_from_zero_cell(HWND hwnd) {
    if (!quick_ff_read_edits(hwnd, 1)) return;
    if (!quick_ff_read_patch_size_controls(hwnd, 1)) return;
    int epi = -1;
    float best = 1.0e30f;
    for (int row = 0; row < g_quick_ff_grid_rows; ++row) {
        for (int col = 0; col < g_quick_ff_grid_cols; ++col) {
            int idx = quick_ff_index(row, col);
            QuickFiniteFaultRow *r = &g_quick_ff_rows[idx];
            if (!r->enabled || r->slip_m <= 0.0f || r->delay_s != 0) continue;
            float ds = (float)(col - QUICK_FF_ORIGIN_COL);
            float dd = (float)(row - QUICK_FF_ORIGIN_ROW);
            float dist = ds * ds + dd * dd;
            if (dist < best) { best = dist; epi = idx; }
        }
    }
    if (epi < 0) {
        int origin = quick_ff_index(QUICK_FF_ORIGIN_ROW, QUICK_FF_ORIGIN_COL);
        if (origin >= 0 && origin < g_quick_ff_count && g_quick_ff_rows[origin].enabled && g_quick_ff_rows[origin].slip_m > 0.0f) epi = origin;
    }
    if (epi < 0) {
        MessageBoxA(hwnd, "No active zero-delay cell found. Set one cell delay to 0 first.", "Finite fault editor", MB_OK | MB_ICONINFORMATION);
        return;
    }
    int epi_row = epi / g_quick_ff_grid_cols;
    int epi_col = epi % g_quick_ff_grid_cols;
    float epi_s = (float)(epi_col - QUICK_FF_ORIGIN_COL) * g_fault_stamp_length_km;
    float epi_d = quick_ff_rel_d_from_row(epi_row) * current_fault_width_km();
    float v = g_rupture_velocity_km_s;
    if (v < 0.10f) v = 0.10f;
    int changed = 0;
    for (int row = 0; row < g_quick_ff_grid_rows; ++row) {
        for (int col = 0; col < g_quick_ff_grid_cols; ++col) {
            int idx = quick_ff_index(row, col);
            QuickFiniteFaultRow *r = &g_quick_ff_rows[idx];
            if (!r->enabled || r->slip_m <= 0.0f) continue;
            float s = (float)(col - QUICK_FF_ORIGIN_COL) * g_fault_stamp_length_km;
            float d = quick_ff_rel_d_from_row(row) * current_fault_width_km();
            float dist_km = sqrtf((s - epi_s) * (s - epi_s) + (d - epi_d) * (d - epi_d));
            r->delay_s = (int)lroundf(dist_km / v);
            changed++;
        }
    }
    quick_ff_write_edits(hwnd);
    quick_ff_refresh_colors(hwnd);
    set_status("Auto delay from zero-delay epicenter cell %s%d applied to %d active cells at rupture speed %.2f km/s.",
               quick_ff_col_name(epi_col), epi_row + 1, changed, v);
}


static void quick_ff_update_mw_preview(HWND hwnd) {
    if (!hwnd) return;
    double mu = (double)g_rigidity_pa;
    if (mu < 1.0e9) mu = 1.0e9;
    double length_km = (double)g_fault_stamp_length_km;
    double width_km = (double)current_fault_width_km();
    if (length_km <= 0.0) length_km = 1.0;
    if (width_km <= 0.0) width_km = 1.0;
    double patch_area_m2 = length_km * width_km * 1000000.0;
    double total_area_km2 = 0.0;
    double slip_sum = 0.0;
    double m0 = 0.0;
    float max_slip = 0.0f;
    int active = 0;
    for (int i = 0; i < g_quick_ff_count; ++i) {
        QuickFiniteFaultRow *r = &g_quick_ff_rows[i];
        if (!r->enabled || r->slip_m <= 0.0f) continue;
        double slip = (double)r->slip_m;
        if (slip < 0.0) slip = -slip;
        active++;
        slip_sum += slip;
        if ((float)slip > max_slip) max_slip = (float)slip;
        total_area_km2 += length_km * width_km;
        m0 += mu * patch_area_m2 * slip;
    }
    m0 *= MOMENT_CALIBRATION_SCALE;

    char msg[256];
    if (active <= 0 || m0 <= 0.0) {
        snprintf(msg, sizeof(msg), "Magnitude preview: no active slip cells | patch %.1f x %.1f km", length_km, width_km);
    } else {
        double mw = (2.0 / 3.0) * (log10(m0) - 9.10);
        double avg_slip = slip_sum / (double)active;
        snprintf(msg, sizeof(msg),
                 "Magnitude preview if placed now: Mw %.2f | M0 %.3e N m | patches %d | area %.0f km2 | avg slip %.2f m | max slip %.2f m",
                 mw, m0, active, total_area_km2, avg_slip, max_slip);
    }
    SetDlgItemTextA(hwnd, ID_QUICK_FF_MW_PREVIEW, msg);
}

static void quick_ff_init_empty_grid(int rows, int cols, int seed_origin) {
    g_quick_ff_grid_rows = clampi_gui(rows, QUICK_FF_MIN_ROWS, QUICK_FF_GRID_ROWS);
    g_quick_ff_grid_cols = clampi_gui(cols, QUICK_FF_MIN_COLS, QUICK_FF_GRID_COLS);
    g_quick_ff_count = g_quick_ff_grid_rows * g_quick_ff_grid_cols;
    quick_ff_clamp_view_origin();
    for (int row = 0; row < g_quick_ff_grid_rows; ++row) {
        for (int col = 0; col < g_quick_ff_grid_cols; ++col) {
            int idx = quick_ff_index(row, col);
            g_quick_ff_rows[idx].enabled = 0;
            g_quick_ff_rows[idx].rel_s = (float)(col - QUICK_FF_ORIGIN_COL);
            g_quick_ff_rows[idx].rel_d = quick_ff_rel_d_from_row(row);
            g_quick_ff_rows[idx].slip_m = 0.0f;
            g_quick_ff_rows[idx].delay_s = 0;
            g_quick_ff_rows[idx].dip_deg = g_fault_dip_deg;
        }
    }
    if (seed_origin && g_quick_ff_grid_rows > QUICK_FF_ORIGIN_ROW && g_quick_ff_grid_cols > QUICK_FF_ORIGIN_COL) {
        int c = quick_ff_index(QUICK_FF_ORIGIN_ROW, QUICK_FF_ORIGIN_COL);
        g_quick_ff_rows[c].enabled = 1;
        g_quick_ff_rows[c].slip_m = fmaxf(1.0f, g_new_source_height_m);
        g_quick_ff_rows[c].delay_s = 0;
        g_quick_ff_rows[c].dip_deg = g_fault_dip_deg;
    }
}

static void quick_ff_init_defaults(void) {
    if (g_quick_ff_count > 0) return;
    quick_ff_init_empty_grid(g_quick_ff_grid_rows, g_quick_ff_grid_cols, 1);
}

static void quick_ff_resize_grid(int new_rows, int new_cols) {
    QuickFiniteFaultRow old_rows[QUICK_FF_MAX_ROWS];
    int old_row_count = g_quick_ff_grid_rows;
    int old_col_count = g_quick_ff_grid_cols;
    int old_count = g_quick_ff_count;
    memcpy(old_rows, g_quick_ff_rows, sizeof(old_rows));

    new_rows = clampi_gui(new_rows, QUICK_FF_MIN_ROWS, QUICK_FF_GRID_ROWS);
    new_cols = clampi_gui(new_cols, QUICK_FF_MIN_COLS, QUICK_FF_GRID_COLS);
    quick_ff_init_empty_grid(new_rows, new_cols, 0);
    quick_ff_clamp_view_origin();

    for (int row = 0; row < g_quick_ff_grid_rows && row < old_row_count; ++row) {
        for (int col = 0; col < g_quick_ff_grid_cols && col < old_col_count; ++col) {
            int old_idx = quick_ff_index_with_cols(row, col, old_col_count);
            int new_idx = quick_ff_index(row, col);
            if (old_idx < 0 || old_idx >= old_count || new_idx < 0 || new_idx >= QUICK_FF_MAX_ROWS) continue;
            g_quick_ff_rows[new_idx] = old_rows[old_idx];
            g_quick_ff_rows[new_idx].rel_s = (float)(col - QUICK_FF_ORIGIN_COL);
            g_quick_ff_rows[new_idx].rel_d = quick_ff_rel_d_from_row(row);
        }
    }
}

static void quick_ff_write_edits(HWND hwnd) {
    char b[128];
    g_quick_ff_updating_controls++;
    quick_ff_clamp_view_origin();
    for (int vr = 0; vr < QUICK_FF_VIEW_ROWS; ++vr) {
        for (int vc = 0; vc < QUICK_FF_VIEW_COLS; ++vc) {
            int row = quick_ff_actual_row_from_view(vr);
            int col = quick_ff_actual_col_from_view(vc);
            if (row >= g_quick_ff_grid_rows || col >= g_quick_ff_grid_cols) {
                SetDlgItemTextA(hwnd, quick_ff_edit_id(vr, vc), "");
                continue;
            }
            int idx = quick_ff_index(row, col);
            QuickFiniteFaultRow *r = &g_quick_ff_rows[idx];
            if (r->enabled && r->slip_m > 0.0f) {
                snprintf(b, sizeof(b), "%.3g\r\n%d\r\n%.1f", r->slip_m, r->delay_s, r->dip_deg);
            } else {
                b[0] = '\0';
            }
            SetDlgItemTextA(hwnd, quick_ff_edit_id(vr, vc), b);
        }
    }
    char view_msg[128];
    char a[16], bcell[16];
    quick_ff_cell_label(g_quick_ff_view_row, g_quick_ff_view_col, a, sizeof(a));
    quick_ff_cell_label(g_quick_ff_view_row + QUICK_FF_VIEW_ROWS - 1, g_quick_ff_view_col + QUICK_FF_VIEW_COLS - 1, bcell, sizeof(bcell));
    snprintf(view_msg, sizeof(view_msg), "Visible cells: %s:%s  (arrow keys move the 5x5 window)", a, bcell);
    SetDlgItemTextA(hwnd, ID_QUICK_FF_VIEW_INFO, view_msg);
    set_edit_textf_if_not_focused(hwnd, ID_QUICK_FF_LEN_EDIT, "%.3g", g_fault_stamp_length_km);
    set_edit_textf_if_not_focused(hwnd, ID_QUICK_FF_WIDTH_EDIT, "%.3g", current_fault_width_km());
    set_edit_textf_if_not_focused(hwnd, ID_QUICK_FF_SLIP_PERCENT_EDIT, "%.3g", g_quick_ff_slip_percent);
    if (g_quick_ff_bulk_dip_deg < 5.0f || g_quick_ff_bulk_dip_deg > 60.0f || !isfinite(g_quick_ff_bulk_dip_deg)) {
        g_quick_ff_bulk_dip_deg = clampf_gui(g_fault_dip_deg, 5.0f, 60.0f);
    }
    set_edit_textf_if_not_focused(hwnd, ID_QUICK_FF_DIP_EDIT, "%.1f", g_quick_ff_bulk_dip_deg);
    SetDlgItemTextA(hwnd, ID_QUICK_FF_DEPTH_DIR_TOGGLE, g_quick_ff_row1_is_shallow ? "Row1: shallow" : "Row1: deep");
    quick_ff_update_mw_preview(hwnd);
    g_quick_ff_updating_controls--;
}
static int quick_ff_parse_cell_text(const char *src, float *slip, int *delay, float *dip, int *enabled) {
    char buf[128];
    snprintf(buf, sizeof(buf), "%s", src ? src : "");
    char *p = buf;
    while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n') p++;
    if (*p == '\0') { *enabled = 0; *slip = 0.0f; *delay = 0; *dip = g_fault_dip_deg; return 1; }
    for (char *q = p; *q; ++q) if (*q == ';' || *q == '/' || *q == '\r' || *q == '\n' || *q == '\t') *q = ',';
    char *tok1 = strtok(p, ",");
    char *tok2 = strtok(NULL, ",");
    char *tok3 = strtok(NULL, ",");
    char *end = NULL;
    if (!tok1) return 0;
    float s = strtof(tok1, &end);
    while (end && (*end == ' ' || *end == '\t')) end++;
    if (end == tok1 || (end && *end != '\0')) return 0;
    float dly = 0.0f;
    float dp = g_fault_dip_deg;
    if (tok2) {
        end = NULL; dly = strtof(tok2, &end);
        while (end && (*end == ' ' || *end == '\t')) end++;
        if (end == tok2 || (end && *end != '\0')) return 0;
    }
    if (tok3) {
        end = NULL; dp = strtof(tok3, &end);
        while (end && (*end == ' ' || *end == '\t')) end++;
        if (end == tok3 || (end && *end != '\0')) return 0;
    }
    *slip = fmaxf(0.0f, s);
    *delay = (int)lroundf(fmaxf(0.0f, dly));
    *dip = clampf_gui(dp, 5.0f, 60.0f);
    *enabled = (*slip > 0.0f);
    return 1;
}
static int quick_ff_read_patch_size_controls(HWND hwnd, int warn) {
    char b[128];
    float v = 0.0f;
    GetDlgItemTextA(hwnd, ID_QUICK_FF_LEN_EDIT, b, sizeof(b));
    if (b[0]) {
        char *end = NULL;
        v = strtof(b, &end);
        while (end && (*end == ' ' || *end == '\t' || *end == '\r' || *end == '\n')) end++;
        if (end == b || (end && *end != '\0') || !isfinite(v) || v <= 0.0f) {
            if (warn) MessageBoxA(hwnd, "Invalid patch length km.", "Finite fault editor", MB_OK | MB_ICONWARNING);
            return 0;
        }
        g_fault_stamp_length_km = normalized_stamp_length_km(v);
    }
    GetDlgItemTextA(hwnd, ID_QUICK_FF_WIDTH_EDIT, b, sizeof(b));
    if (b[0]) {
        char *end = NULL;
        v = strtof(b, &end);
        while (end && (*end == ' ' || *end == '\t' || *end == '\r' || *end == '\n')) end++;
        if (end == b || (end && *end != '\0') || !isfinite(v) || v <= 0.0f) {
            if (warn) MessageBoxA(hwnd, "Invalid patch width km.", "Finite fault editor", MB_OK | MB_ICONWARNING);
            return 0;
        }
        set_fault_width_km_value(v);
    }
    return 1;
}

static int quick_ff_read_edits(HWND hwnd, int warn) {
    char b[128];
    if (!quick_ff_read_patch_size_controls(hwnd, warn)) return 0;
    quick_ff_clamp_view_origin();
    for (int vr = 0; vr < QUICK_FF_VIEW_ROWS; ++vr) {
        for (int vc = 0; vc < QUICK_FF_VIEW_COLS; ++vc) {
            int row = quick_ff_actual_row_from_view(vr);
            int col = quick_ff_actual_col_from_view(vc);
            if (row >= g_quick_ff_grid_rows || col >= g_quick_ff_grid_cols) continue;
            int idx = quick_ff_index(row, col);
            GetDlgItemTextA(hwnd, quick_ff_edit_id(vr, vc), b, sizeof(b));
            float slip = 0.0f, dip = g_fault_dip_deg;
            int delay = 0, enabled = 0;
            if (!quick_ff_parse_cell_text(b, &slip, &delay, &dip, &enabled)) {
                if (warn) {
                    char cell[16], msg[224];
                    quick_ff_cell_label(row, col, cell, sizeof(cell));
                    snprintf(msg, sizeof(msg), "Invalid value in cell %s. Use three lines: slip / delay / dip, or slip,delay,dip.", cell);
                    MessageBoxA(hwnd, msg, "Finite fault editor", MB_OK | MB_ICONWARNING);
                }
                return 0;
            }
            g_quick_ff_rows[idx].enabled = enabled;
            g_quick_ff_rows[idx].rel_s = (float)(col - QUICK_FF_ORIGIN_COL);
            g_quick_ff_rows[idx].rel_d = quick_ff_rel_d_from_row(row);
            g_quick_ff_rows[idx].slip_m = slip;
            g_quick_ff_rows[idx].delay_s = delay;
            g_quick_ff_rows[idx].dip_deg = dip;
        }
    }
    return 1;
}
static int quick_ff_save_csv(HWND hwnd) {
    char file_name[MAX_PATH]="quick_finite_fault_grid.csv"; OPENFILENAMEA ofn; memset(&ofn,0,sizeof(ofn)); ofn.lStructSize=sizeof(ofn); ofn.hwndOwner=hwnd; ofn.lpstrFilter="CSV file\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 quick finite-fault grid CSV"; if(!GetSaveFileNameA(&ofn)) return 0;
    FILE *fp=fopen(file_name,"wb"); if(!fp) return 0;
    fprintf(fp,"# Quick finite-fault Excel-style grid. Epicenter cell = active delay-0 cell. Rows=%d Cols=%d PatchLengthKm=%.9g PatchWidthKm=%.9g Row1=%s. Each cell is slip_m,delay_s,dip_deg.\n", g_quick_ff_grid_rows, g_quick_ff_grid_cols, g_fault_stamp_length_km, current_fault_width_km(), g_quick_ff_row1_is_shallow ? "shallow" : "deep");
    fprintf(fp,"# This matrix format is meant to be easy to edit in Excel. Empty cells mean no patch.\n");
    fprintf(fp,"row");
    for(int col=0; col<g_quick_ff_grid_cols; ++col) fprintf(fp,",%s", quick_ff_col_name(col));
    fprintf(fp,"\n");
    for(int row=0; row<g_quick_ff_grid_rows; ++row) {
        fprintf(fp,"%d", row + 1);
        for(int col=0; col<g_quick_ff_grid_cols; ++col) {
            int idx=quick_ff_index(row,col); QuickFiniteFaultRow *r=&g_quick_ff_rows[idx];
            if(r->enabled || r->slip_m>0.0f) fprintf(fp,",\"%.9g,%d,%.9g\"", r->slip_m, r->delay_s, r->dip_deg);
            else fprintf(fp,",");
        }
        fprintf(fp,"\n");
    }
    fprintf(fp,"\n# Legacy/list format is still accepted when loading:\n");
    fprintf(fp,"# cell,rel_s,rel_d,slip_m,delay_s,dip_deg\n");
    fclose(fp); return 1;
}



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

static int quick_ff_split_csv_line(char *line, char **tok, int max_tok) {
    int n = 0;
    char *out = line;
    char *field_start = out;
    int in_quotes = 0;
    for (char *p = line; *p; ++p) {
        char ch = *p;
        if (ch == '"') {
            if (in_quotes && p[1] == '"') { *out++ = '"'; ++p; }
            else in_quotes = !in_quotes;
        } else if (ch == ',' && !in_quotes) {
            *out++ = '\0';
            if (n < max_tok) tok[n++] = quick_ff_trim_token(field_start);
            field_start = out;
        } else if (ch == '\r' || ch == '\n') {
            break;
        } else {
            *out++ = ch;
        }
    }
    *out = '\0';
    if (n < max_tok) tok[n++] = quick_ff_trim_token(field_start);
    return n;
}

static int quick_ff_token_is_integer(const char *s, int *out_v) {
    if (!s) return 0;
    while (*s == ' ' || *s == '\t') s++;
    if (!*s) return 0;
    char *end = NULL;
    long v = strtol(s, &end, 10);
    while (end && (*end == ' ' || *end == '\t')) end++;
    if (end == s || (end && *end != '\0')) return 0;
    if (out_v) *out_v = (int)v;
    return 1;
}


static int quick_ff_cell_to_row_col(const char *cell, int *out_row, int *out_col) {
    if (!cell || !*cell) return 0;
    int col = 0;
    int i = 0;
    while (cell[i] && ((cell[i] >= 'A' && cell[i] <= 'Z') || (cell[i] >= 'a' && cell[i] <= 'z'))) {
        char ch = cell[i];
        if (ch >= 'a' && ch <= 'z') ch = (char)(ch - 'a' + 'A');
        col = col * 26 + (int)(ch - 'A' + 1);
        i++;
    }
    if (col <= 0) return 0;
    int row = atoi(cell + i);
    if (row <= 0) return 0;
    if (out_row) *out_row = row - 1;
    if (out_col) *out_col = col - 1;
    return 1;
}

static int quick_ff_parse_rows_cols_comment(const char *line, int *rows, int *cols) {
    if (!line) return 0;
    const char *r = strstr(line, "Rows=");
    const char *c = strstr(line, "Cols=");
    const char *lk = strstr(line, "PatchLengthKm=");
    const char *wk = strstr(line, "PatchWidthKm=");
    const char *row1 = strstr(line, "Row1=");
    int changed = 0;
    if (r && rows) { int v = atoi(r + 5); if (v > 0) { *rows = v; changed = 1; } }
    if (c && cols) { int v = atoi(c + 5); if (v > 0) { *cols = v; changed = 1; } }
    if (lk) { float v = strtof(lk + 14, NULL); if (isfinite(v) && v > 0.0f) { g_fault_stamp_length_km = normalized_stamp_length_km(v); changed = 1; } }
    if (wk) { float v = strtof(wk + 13, NULL); if (isfinite(v) && v > 0.0f) { set_fault_width_km_value(v); changed = 1; } }
    if (row1) { const char *v = row1 + 5; if (_strnicmp(v, "deep", 4) == 0) { g_quick_ff_row1_is_shallow = 0; changed = 1; } else if (_strnicmp(v, "shallow", 7) == 0) { g_quick_ff_row1_is_shallow = 1; changed = 1; } }
    return changed;
}

typedef struct QuickFFLoadedCellTmp {
    int row, col, delay, enabled;
    float slip, dip;
} QuickFFLoadedCellTmp;

static int quick_ff_loaded_cell_add(int row, int col, float slip, int delay, float dip,
                                    int *cell_count, int *max_row, int *max_col,
                                    QuickFFLoadedCellTmp *cells) {
    if (row < 0 || col < 0 || row >= QUICK_FF_GRID_ROWS || col >= QUICK_FF_GRID_COLS) return 0;
    if (*cell_count >= QUICK_FF_MAX_ROWS) return 0;
    cells[*cell_count].row = row;
    cells[*cell_count].col = col;
    cells[*cell_count].slip = fmaxf(0.0f, slip);
    cells[*cell_count].delay = delay < 0 ? 0 : delay;
    cells[*cell_count].dip = clampf_gui(dip, 5.0f, 60.0f);
    cells[*cell_count].enabled = (cells[*cell_count].slip > SOURCE_SLIP_HEIGHT_EPS_M);
    (*cell_count)++;
    if (row > *max_row) *max_row = row;
    if (col > *max_col) *max_col = col;
    return 1;
}

static int quick_ff_token_is_col_label(const char *s) {
    if (!s) return 0;
    while (*s == ' ' || *s == '\t') s++;
    if (!*s) return 0;
    int n = 0;
    while (*s) {
        char ch = *s++;
        if (ch == ' ' || ch == '\t') break;
        if (!((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z'))) return 0;
        n++;
    }
    return n > 0 && n <= 3;
}

static int quick_ff_line_is_matrix_header(char **tok, int ntok) {
    if (ntok <= 0) return 0;
    if (_stricmp(tok[0], "cell") == 0 || _stricmp(tok[0], "row") == 0) return 1;
    int labels = 0;
    int checked = 0;
    for (int i = 0; i < ntok; ++i) {
        if (!tok[i] || !*tok[i]) continue;
        checked++;
        if (quick_ff_token_is_col_label(tok[i])) labels++;
    }
    return checked > 0 && labels == checked;
}

static int quick_ff_parse_ref_with_inline_payload(char *field, int *out_row, int *out_col,
                                                  float *out_slip, int *out_delay, float *out_dip, int *out_enabled) {
    if (!field || !*field) return 0;
    char *s = quick_ff_trim_token(field);
    int col = 0;
    int i = 0;
    while (s[i] && ((s[i] >= 'A' && s[i] <= 'Z') || (s[i] >= 'a' && s[i] <= 'z'))) {
        char ch = s[i];
        if (ch >= 'a' && ch <= 'z') ch = (char)(ch - 'a' + 'A');
        col = col * 26 + (int)(ch - 'A' + 1);
        i++;
    }
    if (col <= 0) return 0;
    char *row_start = s + i;
    if (!(*row_start >= '0' && *row_start <= '9')) return 0;
    int row = atoi(row_start);
    while (s[i] >= '0' && s[i] <= '9') i++;
    if (row <= 0) return 0;
    char *payload = s + i;
    while (*payload == ' ' || *payload == '\t' || *payload == ',' || *payload == ';' || *payload == ':') payload++;
    if (!*payload) return 0;
    if (!quick_ff_parse_cell_text(payload, out_slip, out_delay, out_dip, out_enabled)) return 0;
    if (out_row) *out_row = row - 1;
    if (out_col) *out_col = col - 1;
    return 1;
}

static int quick_ff_load_csv(HWND hwnd) {
    char file_name[MAX_PATH] = "quick_finite_fault_grid.csv";
    OPENFILENAMEA ofn;
    memset(&ofn, 0, sizeof(ofn));
    ofn.lStructSize = sizeof(ofn);
    ofn.hwndOwner = hwnd;
    ofn.lpstrFilter = "CSV / text file\0*.csv;*.txt\0CSV file\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 quick finite-fault grid CSV";
    if (!GetOpenFileNameA(&ofn)) return 0;

    FILE *fp = fopen(file_name, "rb");
    if (!fp) {
        MessageBoxA(hwnd, "Failed to open quick finite-fault CSV.", "Finite fault editor", MB_OK | MB_ICONWARNING);
        return 0;
    }

    QuickFFLoadedCellTmp cells[QUICK_FF_MAX_ROWS];
    memset(cells, 0, sizeof(cells));
    int cell_count = 0;
    int desired_rows = g_quick_ff_grid_rows;
    int desired_cols = g_quick_ff_grid_cols;
    int max_row = -1;
    int max_col = -1;
    int matrix_row_cursor = 0;
    int imported_matrix_rows = 0;
    int imported_list_rows = 0;
    int saw_legacy_header = 0;

    char line[4096];
    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 == '\r' || *p == '\n') p++;
        if (*p == '\0') continue;
        if (*p == '#') {
            quick_ff_parse_rows_cols_comment(p, &desired_rows, &desired_cols);
            continue;
        }
        for (char *q = p; *q; ++q) if (*q == '\t') *q = ',';

        char work[4096];
        strncpy(work, p, sizeof(work) - 1);
        work[sizeof(work) - 1] = '\0';
        char *tok[256] = {0};
        int ntok = quick_ff_split_csv_line(work, tok, (int)(sizeof(tok) / sizeof(tok[0])));
        if (ntok <= 0) continue;

        /* v116: explicitly detect the old saved-list header from v103/v105.
           Example user file:
             cell,rel_s,rel_d,slip_m,delay_s,dip_deg
             A1,-2,-2,0.003,12,17
           Treat all following cell-reference rows as list rows, not matrix rows. */
        if (ntok >= 6 && _stricmp(tok[0], "cell") == 0 &&
            _stricmp(tok[1], "rel_s") == 0 && _stricmp(tok[2], "rel_d") == 0) {
            saw_legacy_header = 1;
            continue;
        }
        if (_stricmp(tok[0], "cell") == 0 || _stricmp(tok[0], "row") == 0) {
            continue;
        }

        int row = -1, col = -1;
        if (quick_ff_cell_to_row_col(tok[0], &row, &col)) {
            float slip = 0.0f, dip = g_fault_dip_deg;
            int delay = 0, enabled = 0;
            int parsed = 0;
            if (ntok >= 6) {
                /* Legacy explicit format: cell,rel_s,rel_d,slip_m,delay_s,dip_deg.
                   rel_s/rel_d are ignored because the Excel cell itself is authoritative. */
                slip = (float)atof(tok[3]);
                delay = atoi(tok[4]);
                dip = (float)atof(tok[5]);
                enabled = slip > SOURCE_SLIP_HEIGHT_EPS_M;
                parsed = 1;
            } else if (ntok >= 4) {
                /* Simple list format: C3,slip,delay,dip. */
                slip = (float)atof(tok[1]);
                delay = atoi(tok[2]);
                dip = (float)atof(tok[3]);
                enabled = slip > SOURCE_SLIP_HEIGHT_EPS_M;
                parsed = 1;
            } else if (ntok >= 2) {
                char payload[256] = {0};
                snprintf(payload, sizeof(payload), "%s", tok[1]);
                parsed = quick_ff_parse_cell_text(payload, &slip, &delay, &dip, &enabled);
            } else {
                char payload[256] = {0};
                int rr = -1, cc = -1;
                snprintf(payload, sizeof(payload), "%s", tok[0]);
                parsed = quick_ff_parse_ref_with_inline_payload(payload, &rr, &cc, &slip, &delay, &dip, &enabled);
                if (parsed) { row = rr; col = cc; }
            }
            if (parsed) {
                quick_ff_loaded_cell_add(row, col, slip, delay, dip, &cell_count, &max_row, &max_col, cells);
                imported_list_rows++;
            }
            continue;
        }

        if (saw_legacy_header) {
            /* In legacy mode, non-cell rows are comments/invalid rows rather than matrix data. */
            continue;
        }

        if (quick_ff_line_is_matrix_header(tok, ntok)) continue;

        int any_ref_payload = 0;
        for (int ti = 0; ti < ntok; ++ti) {
            if (!tok[ti] || !*tok[ti]) continue;
            char payload[256];
            snprintf(payload, sizeof(payload), "%s", tok[ti]);
            float slip = 0.0f, dip = g_fault_dip_deg;
            int delay = 0, enabled = 0, rr = -1, cc = -1;
            if (quick_ff_parse_ref_with_inline_payload(payload, &rr, &cc, &slip, &delay, &dip, &enabled)) {
                quick_ff_loaded_cell_add(rr, cc, slip, delay, dip, &cell_count, &max_row, &max_col, cells);
                any_ref_payload = 1;
                imported_list_rows++;
            }
        }
        if (any_ref_payload) continue;

        int row_number = 0;
        int has_row_number = quick_ff_token_is_integer(tok[0], &row_number) && row_number >= 1 && row_number <= QUICK_FF_GRID_ROWS;
        int matrix_row = has_row_number ? (row_number - 1) : matrix_row_cursor;
        int first_col_token = has_row_number ? 1 : 0;
        if (matrix_row < 0 || matrix_row >= QUICK_FF_GRID_ROWS) {
            if (!has_row_number) matrix_row_cursor++;
            continue;
        }

        int nonempty = 0;
        int all_numericish = 1;
        for (int ti = first_col_token; ti < ntok; ++ti) {
            char *v = quick_ff_trim_token(tok[ti]);
            if (!v || !*v) continue;
            nonempty++;
            char *endp = NULL;
            (void)strtof(v, &endp);
            while (endp && (*endp == ' ' || *endp == '\t')) endp++;
            if (endp == v || (endp && *endp != '\0')) all_numericish = 0;
        }
        if (nonempty >= 3 && (nonempty % 3) == 0 && all_numericish) {
            int out_col = 0;
            for (int ti = first_col_token; ti + 2 < ntok && out_col < QUICK_FF_GRID_COLS; ) {
                while (ti < ntok && (!tok[ti] || !*quick_ff_trim_token(tok[ti]))) ti++;
                if (ti + 2 >= ntok) break;
                float slip = (float)atof(tok[ti]);
                int delay = atoi(tok[ti + 1]);
                float dip = (float)atof(tok[ti + 2]);
                quick_ff_loaded_cell_add(matrix_row, out_col, slip, delay, dip, &cell_count, &max_row, &max_col, cells);
                out_col++;
                ti += 3;
            }
            matrix_row_cursor++;
            imported_matrix_rows++;
            continue;
        }

        int any_data = 0;
        for (int ti = first_col_token; ti < ntok; ++ti) {
            int matrix_col = ti - first_col_token;
            if (matrix_col < 0 || matrix_col >= QUICK_FF_GRID_COLS) continue;
            char *cell_text = quick_ff_trim_token(tok[ti]);
            if (!cell_text || !*cell_text) continue;
            float slip = 0.0f, dip = g_fault_dip_deg;
            int delay = 0, enabled = 0;
            if (!quick_ff_parse_cell_text(cell_text, &slip, &delay, &dip, &enabled)) continue;
            if (!enabled && slip <= SOURCE_SLIP_HEIGHT_EPS_M) continue;
            quick_ff_loaded_cell_add(matrix_row, matrix_col, slip, delay, dip, &cell_count, &max_row, &max_col, cells);
            any_data = 1;
        }
        if (any_data) imported_matrix_rows++;
        if (any_data || !has_row_number) matrix_row_cursor++;
    }
    fclose(fp);

    if (cell_count <= 0) {
        MessageBoxA(hwnd,
            "No valid finite-fault cells found. Accepted examples:\n"
            "  Legacy list: cell,rel_s,rel_d,slip_m,delay_s,dip_deg then A1,-2,-2,0.003,12,17\n"
            "  Matrix CSV: row,A,B,C then cells like \"12.5,60,15\"\n"
            "  Simple list: C3,12.5,60,15\n"
            "  Unquoted triples: 12.5,60,15,8,90,20",
            "Finite fault editor", MB_OK | MB_ICONWARNING);
        return 0;
    }

    desired_rows = clampi_gui(desired_rows, QUICK_FF_MIN_ROWS, QUICK_FF_GRID_ROWS);
    desired_cols = clampi_gui(desired_cols, QUICK_FF_MIN_COLS, QUICK_FF_GRID_COLS);
    if (max_row + 1 > desired_rows) desired_rows = clampi_gui(max_row + 1, QUICK_FF_MIN_ROWS, QUICK_FF_GRID_ROWS);
    if (max_col + 1 > desired_cols) desired_cols = clampi_gui(max_col + 1, QUICK_FF_MIN_COLS, QUICK_FF_GRID_COLS);

    quick_ff_init_empty_grid(desired_rows, desired_cols, 0);
    for (int i = 0; i < cell_count; ++i) {
        if (cells[i].row >= g_quick_ff_grid_rows || cells[i].col >= g_quick_ff_grid_cols) continue;
        int idx = quick_ff_index(cells[i].row, cells[i].col);
        QuickFiniteFaultRow *r = &g_quick_ff_rows[idx];
        r->enabled = cells[i].enabled;
        r->rel_s = (float)(cells[i].col - QUICK_FF_ORIGIN_COL);
        r->rel_d = quick_ff_rel_d_from_row(cells[i].row);
        r->slip_m = cells[i].slip;
        r->delay_s = cells[i].delay;
        r->dip_deg = cells[i].dip;
    }

    set_status("Loaded quick finite-fault CSV: %d cells (%d matrix rows, %d list/ref rows).", cell_count, imported_matrix_rows, imported_list_rows);
    return 1;
}
static void quick_ff_enter_place_mode(HWND owner) {
    g_quick_ff_place_mode = 1;
    if (owner) SetFocus(owner);
    set_status("Quick finite-fault placement: mouse=epicenter C3; click/Enter apply; Z/X strike %.0f; J/K length %.1f km; </> width %.1f km; Esc cancels.", g_fault_strike_deg, g_fault_stamp_length_km, current_fault_width_km());
}
static void quick_ff_close_editor(HWND hwnd) {
    if (!quick_ff_read_edits(hwnd, 1)) return;
    int r = MessageBoxA(hwnd, "Save the quick finite-fault grid as CSV before placement?", "Quick finite-fault editor", MB_YESNOCANCEL | MB_ICONQUESTION);
    if (r == IDCANCEL) return;
    if (r == IDYES) quick_ff_save_csv(hwnd);
    if (r == IDNO) {
        int rr = MessageBoxA(hwnd, "Proceed without saving this grid?", "Quick finite-fault editor", MB_YESNO | MB_ICONWARNING);
        if (rr != IDYES) return;
    }
    HWND owner = GetParent(hwnd);
    DestroyWindow(hwnd);
    g_quick_ff_hwnd = NULL;
    quick_ff_enter_place_mode(owner ? owner : g_main_hwnd);
}
static void quick_ff_invalidate_cell_color_controls(HWND hwnd) {
    if (!hwnd) return;
    for (int vr = 0; vr < QUICK_FF_VIEW_ROWS; ++vr) {
        for (int vc = 0; vc < QUICK_FF_VIEW_COLS; ++vc) {
            HWND cell = GetDlgItem(hwnd, quick_ff_edit_id(vr, vc));
            if (cell) {
                InvalidateRect(cell, NULL, TRUE);
                UpdateWindow(cell);
            }
        }
    }
}

static void quick_ff_refresh_colors(HWND hwnd) {
    quick_ff_invalidate_cell_color_controls(hwnd);
    InvalidateRect(hwnd, NULL, TRUE);
}

static void quick_ff_update_visible_window(HWND hwnd) {
    if (!hwnd) return;
    quick_ff_clamp_view_origin();
    for (int vc = 0; vc < QUICK_FF_VIEW_COLS; ++vc) {
        int c = quick_ff_actual_col_from_view(vc);
        if (g_quick_ff_col_label_hwnd[vc]) {
            SetWindowTextA(g_quick_ff_col_label_hwnd[vc], quick_ff_col_name(c));
            InvalidateRect(g_quick_ff_col_label_hwnd[vc], NULL, TRUE);
        }
    }
    for (int vr = 0; vr < QUICK_FF_VIEW_ROWS; ++vr) {
        int r = quick_ff_actual_row_from_view(vr);
        char rn[16];
        snprintf(rn, sizeof(rn), "%d", r + 1);
        if (g_quick_ff_row_label_hwnd[vr]) {
            SetWindowTextA(g_quick_ff_row_label_hwnd[vr], rn);
            InvalidateRect(g_quick_ff_row_label_hwnd[vr], NULL, TRUE);
        }
    }
    quick_ff_write_edits(hwnd);
    quick_ff_refresh_colors(hwnd);
}

static int quick_ff_read_grid_size_controls(HWND hwnd, int *rows, int *cols) {
    char rb[32], cb[32];
    GetDlgItemTextA(hwnd, ID_QUICK_FF_ROWS_EDIT, rb, sizeof(rb));
    GetDlgItemTextA(hwnd, ID_QUICK_FF_COLS_EDIT, cb, sizeof(cb));
    int r = atoi(rb);
    int c = atoi(cb);
    if (r < QUICK_FF_MIN_ROWS || r > QUICK_FF_GRID_ROWS || c < QUICK_FF_MIN_COLS || c > QUICK_FF_GRID_COLS) {
        char msg[160];
        snprintf(msg, sizeof(msg), "Rows/cols must be %d..%d and %d..%d so the C3 epicenter cell exists.",
                 QUICK_FF_MIN_ROWS, QUICK_FF_GRID_ROWS, QUICK_FF_MIN_COLS, QUICK_FF_GRID_COLS);
        MessageBoxA(hwnd, msg, "Finite fault editor", MB_OK | MB_ICONWARNING);
        return 0;
    }
    if (rows) *rows = r;
    if (cols) *cols = c;
    return 1;
}

static void quick_ff_reopen_editor(HWND hwnd) {
    HWND owner = GetParent(hwnd);
    DestroyWindow(hwnd);
    g_quick_ff_hwnd = NULL;
    open_quick_finite_fault_editor(owner ? owner : g_main_hwnd);
}

static LRESULT CALLBACK quick_ff_wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam) {
    switch(msg) {
    case WM_CREATE: {
        quick_ff_init_defaults();
        if (g_quick_ff_bulk_dip_deg < 5.0f || g_quick_ff_bulk_dip_deg > 60.0f || !isfinite(g_quick_ff_bulk_dip_deg)) {
            g_quick_ff_bulk_dip_deg = clampf_gui(g_fault_dip_deg, 5.0f, 60.0f);
        }
        quick_ff_clamp_view_origin();
        make_child(hwnd,"STATIC","Quick finite-fault grid editor. C3 is the epicenter cell. Each visible bingo cell has 3 lines: slip / delay / dip.",0,10,8,1110,22,-1);
        make_child(hwnd,"STATIC","Use arrow keys to move the 5x5 window across a larger grid. Strike and final patch placement are adjusted after closing.",0,10,30,1110,20,-1);
        int x0=58,y0=132,cw=116,ch=78;
        int button_x=x0+QUICK_FF_VIEW_COLS*cw+36;
        char tmp[32];
        make_child(hwnd,"STATIC","Rows",0,58,62,38,20,-1);
        snprintf(tmp,sizeof(tmp),"%d",g_quick_ff_grid_rows);
        make_child(hwnd,"EDIT",tmp,WS_BORDER|ES_AUTOHSCROLL,100,58,50,24,ID_QUICK_FF_ROWS_EDIT);
        make_child(hwnd,"STATIC","Cols",0,158,62,38,20,-1);
        snprintf(tmp,sizeof(tmp),"%d",g_quick_ff_grid_cols);
        make_child(hwnd,"EDIT",tmp,WS_BORDER|ES_AUTOHSCROLL,200,58,50,24,ID_QUICK_FF_COLS_EDIT);
        make_child(hwnd,"BUTTON","Resize grid",BS_PUSHBUTTON,266,56,120,30,ID_QUICK_FF_RESIZE);
        make_child(hwnd,"STATIC","Patch L km",0,410,62,76,20,-1);
        snprintf(tmp,sizeof(tmp),"%.3g",g_fault_stamp_length_km);
        make_child(hwnd,"EDIT",tmp,WS_BORDER|ES_AUTOHSCROLL,490,58,62,24,ID_QUICK_FF_LEN_EDIT);
        make_child(hwnd,"STATIC","W km",0,562,62,42,20,-1);
        snprintf(tmp,sizeof(tmp),"%.3g",current_fault_width_km());
        make_child(hwnd,"EDIT",tmp,WS_BORDER|ES_AUTOHSCROLL,604,58,62,24,ID_QUICK_FF_WIDTH_EDIT);
        make_child(hwnd,"BUTTON",g_quick_ff_row1_is_shallow ? "Row1: shallow" : "Row1: deep",BS_PUSHBUTTON,680,54,126,30,ID_QUICK_FF_DEPTH_DIR_TOGGLE);
        make_child(hwnd,"BUTTON","Auto delay from 0",BS_PUSHBUTTON,814,54,150,30,ID_QUICK_FF_AUTO_DELAY);
        make_child(hwnd,"STATIC","Slip % of focused",0,974,58,112,20,-1);
        snprintf(tmp,sizeof(tmp),"%.3g",g_quick_ff_slip_percent);
        make_child(hwnd,"EDIT",tmp,WS_BORDER|ES_AUTOHSCROLL,1088,56,58,24,ID_QUICK_FF_SLIP_PERCENT_EDIT);
        make_child(hwnd,"BUTTON","Apply slip %",BS_PUSHBUTTON,1152,54,120,30,ID_QUICK_FF_APPLY_SLIP_PERCENT);
        make_child(hwnd,"STATIC","Dip all",0,680,88,50,20,-1);
        snprintf(tmp,sizeof(tmp),"%.1f",clampf_gui(g_quick_ff_bulk_dip_deg,5.0f,60.0f));
        make_child(hwnd,"EDIT",tmp,WS_BORDER|ES_AUTOHSCROLL,734,84,58,24,ID_QUICK_FF_DIP_EDIT);
        make_child(hwnd,"BUTTON","Apply dip to active",BS_PUSHBUTTON,800,82,156,30,ID_QUICK_FF_APPLY_DIP_ALL);
        make_child(hwnd,"STATIC","Grid 3..60. C3 stays the epicenter; visible window is 5x5.",0,964,86,310,20,-1);
        make_child(hwnd,"STATIC","",0,58,96,560,22,ID_QUICK_FF_VIEW_INFO);
        make_child(hwnd,"STATIC","Magnitude preview: no active slip cells",0,680,108,590,42,ID_QUICK_FF_MW_PREVIEW);
        for(int vc=0;vc<QUICK_FF_VIEW_COLS;++vc) {
            int c=quick_ff_actual_col_from_view(vc);
            g_quick_ff_col_label_hwnd[vc] = make_child(hwnd,"STATIC",quick_ff_col_name(c),SS_CENTER,x0+vc*cw,y0-24,cw-6,20,-1);
        }
        for(int vr=0;vr<QUICK_FF_VIEW_ROWS;++vr) {
            int r=quick_ff_actual_row_from_view(vr);
            char rn[16]; snprintf(rn,sizeof(rn),"%d",r+1);
            g_quick_ff_row_label_hwnd[vr] = make_child(hwnd,"STATIC",rn,SS_CENTER,x0-38,y0+vr*ch+24,30,20,-1);
        }
        for(int vr=0;vr<QUICK_FF_VIEW_ROWS;++vr) {
            for(int vc=0;vc<QUICK_FF_VIEW_COLS;++vc) {
                int row=quick_ff_actual_row_from_view(vr);
                int col=quick_ff_actual_col_from_view(vc);
                DWORD style=WS_BORDER|ES_MULTILINE|ES_AUTOVSCROLL|ES_CENTER|ES_WANTRETURN;
                if(row==QUICK_FF_ORIGIN_ROW && col==QUICK_FF_ORIGIN_COL) style|=ES_CENTER;
                make_child(hwnd,"EDIT","",style,x0+vc*cw,y0+vr*ch,cw-7,ch-5,quick_ff_edit_id(vr,vc));
            }
        }
        make_child(hwnd,"STATIC","Color by:",0,button_x,124,140,20,-1);
        make_child(hwnd,"BUTTON","Slip",BS_PUSHBUTTON,button_x,148,150,32,ID_QUICK_FF_COLOR_SLIP);
        make_child(hwnd,"BUTTON","Delay",BS_PUSHBUTTON,button_x,186,150,32,ID_QUICK_FF_COLOR_DELAY);
        make_child(hwnd,"BUTTON","Load CSV",BS_PUSHBUTTON,button_x,234,150,32,ID_QUICK_FF_LOAD);
        make_child(hwnd,"BUTTON","Save CSV",BS_PUSHBUTTON,button_x,272,150,32,ID_QUICK_FF_SAVE);
        make_child(hwnd,"BUTTON","Clear grid",BS_PUSHBUTTON,button_x,320,150,32,ID_QUICK_FF_CLEAR);
        make_child(hwnd,"BUTTON","Apply / placement",BS_PUSHBUTTON,button_x,382,170,38,ID_QUICK_FF_APPLY);
        make_child(hwnd,"BUTTON","Close",BS_PUSHBUTTON,button_x,430,170,36,ID_QUICK_FF_CLOSE);
        make_child(hwnd,"STATIC","Color scale: white -> cyan(20%) -> green(40%) -> yellow(60%) -> red(max).",0,58,y0+QUICK_FF_VIEW_ROWS*ch+16,820,22,-1);
        quick_ff_write_edits(hwnd);
        return 0;
    }
    case WM_COMMAND: {
        int cmd_id = LOWORD(wparam);
        int notify = HIWORD(wparam);
        int edit_row = quick_ff_row_from_id(cmd_id);
        int edit_col = quick_ff_col_from_id(cmd_id);
        if (notify == EN_CHANGE && cmd_id >= ID_QUICK_FF_EDIT_BASE &&
            edit_row >= 0 && edit_row < QUICK_FF_VIEW_ROWS &&
            edit_col >= 0 && edit_col < QUICK_FF_VIEW_COLS) {
            if (g_quick_ff_updating_controls) return 0;
            /* v117: any user cell edit can change the max slip/delay scale, so repaint
               every visible cell.  Programmatic SetDlgItemText during load/reopen is
               suppressed above; otherwise the first filled cell could cause the still-
               blank cells to be read back and wipe the loaded grid. */
            quick_ff_read_edits(hwnd, 0);
            quick_ff_refresh_colors(hwnd);
            return 0;
        }
        if (notify == EN_CHANGE && cmd_id == ID_QUICK_FF_SLIP_PERCENT_EDIT) {
            if (g_quick_ff_updating_controls) return 0;
            quick_ff_read_slip_percent_control(hwnd, 0);
            return 0;
        }
        if (notify == EN_CHANGE && cmd_id == ID_QUICK_FF_DIP_EDIT) {
            if (g_quick_ff_updating_controls) return 0;
            quick_ff_read_bulk_dip_control(hwnd, 0);
            return 0;
        }
        if (notify == EN_CHANGE && (cmd_id == ID_QUICK_FF_LEN_EDIT || cmd_id == ID_QUICK_FF_WIDTH_EDIT)) {
            if (g_quick_ff_updating_controls) return 0;
            if (quick_ff_read_patch_size_controls(hwnd, 0)) quick_ff_update_mw_preview(hwnd);
            return 0;
        }
        switch(cmd_id) {
        case ID_QUICK_FF_COLOR_SLIP: quick_ff_read_edits(hwnd,0); g_quick_ff_color_mode=0; quick_ff_refresh_colors(hwnd); return 0;
        case ID_QUICK_FF_COLOR_DELAY: quick_ff_read_edits(hwnd,0); g_quick_ff_color_mode=1; quick_ff_refresh_colors(hwnd); return 0;
        case ID_QUICK_FF_DEPTH_DIR_TOGGLE:
            quick_ff_read_edits(hwnd,0);
            g_quick_ff_row1_is_shallow = !g_quick_ff_row1_is_shallow;
            quick_ff_recompute_relative_grid();
            SetDlgItemTextA(hwnd, ID_QUICK_FF_DEPTH_DIR_TOGGLE, g_quick_ff_row1_is_shallow ? "Row1: shallow" : "Row1: deep");
            quick_ff_write_edits(hwnd);
            quick_ff_refresh_colors(hwnd);
            set_status("Quick FF depth orientation: row 1 is %s.", g_quick_ff_row1_is_shallow ? "shallow/up-dip" : "deep/down-dip");
            return 0;
        case ID_QUICK_FF_AUTO_DELAY:
            quick_ff_auto_delay_from_zero_cell(hwnd);
            return 0;
        case ID_QUICK_FF_APPLY_SLIP_PERCENT:
            quick_ff_apply_slip_percent_from_focused_cell(hwnd);
            return 0;
        case ID_QUICK_FF_APPLY_DIP_ALL:
            quick_ff_apply_bulk_dip_to_active_cells(hwnd);
            return 0;
        case ID_QUICK_FF_RESIZE: {
            quick_ff_read_edits(hwnd,0);
            int nr=0,nc=0;
            if(!quick_ff_read_grid_size_controls(hwnd,&nr,&nc)) return 0;
            quick_ff_resize_grid(nr,nc);
            quick_ff_reopen_editor(hwnd);
            return 0;
        }
        case ID_QUICK_FF_LOAD:
            if (quick_ff_load_csv(hwnd)) quick_ff_reopen_editor(hwnd);
            return 0;
        case ID_QUICK_FF_SAVE:
            quick_ff_read_edits(hwnd,0);
            quick_ff_save_csv(hwnd);
            return 0;
        case ID_QUICK_FF_ADD_ROW: return 0;
        case ID_QUICK_FF_CLEAR:
            for(int i=0;i<g_quick_ff_count;++i){ g_quick_ff_rows[i].enabled=0; g_quick_ff_rows[i].slip_m=0.0f; g_quick_ff_rows[i].delay_s=0; g_quick_ff_rows[i].dip_deg=g_fault_dip_deg; }
            quick_ff_write_edits(hwnd); quick_ff_refresh_colors(hwnd); return 0;
        case ID_QUICK_FF_APPLY:
        case ID_QUICK_FF_CLOSE: quick_ff_close_editor(hwnd); return 0;
        }
        break; }
    case WM_CTLCOLOREDIT: {
        HWND ctrl=(HWND)lparam; int id=GetDlgCtrlID(ctrl); int vr=quick_ff_row_from_id(id); int vc=quick_ff_col_from_id(id);
        if(id>=ID_QUICK_FF_EDIT_BASE && vr>=0 && vr<QUICK_FF_VIEW_ROWS && vc>=0 && vc<QUICK_FF_VIEW_COLS) {
            if (!g_quick_ff_updating_controls) quick_ff_read_edits(hwnd,0);
            int row = quick_ff_actual_row_from_view(vr);
            int col = quick_ff_actual_col_from_view(vc);
            COLORREF c=quick_ff_cell_color(row,col); SetBkColor((HDC)wparam,c); SetTextColor((HDC)wparam, RGB(0,0,0));
            static HBRUSH brushes[QUICK_FF_VIEW_ROWS][QUICK_FF_VIEW_COLS]; static COLORREF brush_color[QUICK_FF_VIEW_ROWS][QUICK_FF_VIEW_COLS];
            if(!brushes[vr][vc] || brush_color[vr][vc]!=c){ if(brushes[vr][vc]) DeleteObject(brushes[vr][vc]); brushes[vr][vc]=CreateSolidBrush(c); brush_color[vr][vc]=c; }
            return (LRESULT)brushes[vr][vc];
        }
        break; }
    case WM_KEYDOWN:
        if (wparam == VK_LEFT || wparam == VK_RIGHT || wparam == VK_UP || wparam == VK_DOWN) {
            quick_ff_read_edits(hwnd,0);
            if (wparam == VK_LEFT) g_quick_ff_view_col--;
            else if (wparam == VK_RIGHT) g_quick_ff_view_col++;
            else if (wparam == VK_UP) g_quick_ff_view_row--;
            else if (wparam == VK_DOWN) g_quick_ff_view_row++;
            quick_ff_clamp_view_origin();
            /* v120: do not destroy/recreate the editor for arrow-key viewport
               scrolling.  Reopening the tool window caused it to flash and
               jump diagonally because Windows chose a new default window
               position each time.  Just rewrite the visible 5x5 cells and
               labels in-place. */
            quick_ff_update_visible_window(hwnd);
            return 0;
        }
        break;
    case WM_CLOSE: quick_ff_close_editor(hwnd); return 0;
    case WM_DESTROY:
        if(g_quick_ff_hwnd==hwnd) g_quick_ff_hwnd=NULL;
        for (int i = 0; i < QUICK_FF_VIEW_COLS; ++i) g_quick_ff_col_label_hwnd[i] = NULL;
        for (int i = 0; i < QUICK_FF_VIEW_ROWS; ++i) g_quick_ff_row_label_hwnd[i] = NULL;
        return 0;
    }
    return DefWindowProcA(hwnd,msg,wparam,lparam);
}

static void open_quick_finite_fault_editor(HWND owner) {
    if(g_quick_ff_hwnd){ ShowWindow(g_quick_ff_hwnd,SW_SHOWNORMAL); SetForegroundWindow(g_quick_ff_hwnd); return; }
    quick_ff_init_defaults();
    static int registered=0; HINSTANCE inst=(HINSTANCE)GetWindowLongPtr(owner,GWLP_HINSTANCE);
    if(!registered){ WNDCLASSA wc; memset(&wc,0,sizeof(wc)); wc.lpfnWndProc=quick_ff_wnd_proc; wc.hInstance=inst; wc.lpszClassName="QuickFiniteFaultEditor"; wc.hCursor=LoadCursor(NULL,IDC_ARROW); wc.hbrBackground=(HBRUSH)(COLOR_BTNFACE+1); RegisterClassA(&wc); registered=1; }
    g_quick_ff_hwnd=CreateWindowExA(WS_EX_TOOLWINDOW,"QuickFiniteFaultEditor","Quick finite-fault grid editor",WS_OVERLAPPED|WS_CAPTION|WS_SYSMENU,CW_USEDEFAULT,CW_USEDEFAULT,1320,650,owner,NULL,inst,NULL);
    if(g_quick_ff_hwnd){ ShowWindow(g_quick_ff_hwnd,SW_SHOWNORMAL); UpdateWindow(g_quick_ff_hwnd); }
}
static void quick_ff_rect_corners(float cx,float cy,float half_s,float half_n,float sx,float sy,float nx,float ny,POINT out[4]) {
    float xs[4]={-half_s,half_s,half_s,-half_s}; float ns[4]={-half_n,-half_n,half_n,half_n};
    for(int i=0;i<4;++i){ float gx=cx+xs[i]*sx+ns[i]*nx; float gy=cy+xs[i]*sy+ns[i]*ny; out[i].x=grid_to_screen_x_float(gx); out[i].y=grid_to_screen_y_float(gy); }
}
static float quick_ff_depth_from_global_down_dip_cells(const QuickFiniteFaultRow *r,
                                                        float global_dn_cells,
                                                        float km_per_cell) {
    if(!r) return g_source_depth_km;
    float dip=clampf_gui(r->dip_deg,5.0f,60.0f);
    float dip_rad=dip*(TSUNAMI_PI/180.0f);
    if(km_per_cell<0.01f) km_per_cell=0.01f;
    /* v110: tie depth directly to the actual displayed down-dip map distance
       from the delay-0 epicenter, instead of separately scaling row depth by the UI
       width and then adding local patch depth.  This removes the apparent depth
       distribution stretching/compression when ,/. changes the placement width:
       the preview geometry and the injected depth field now use the same axis. */
    float projected_km=global_dn_cells*km_per_cell;
    float depth=g_source_depth_km + projected_km*tanf(dip_rad);
    return clampf_gui(depth,0.0f,700.0f);
}

static float quick_ff_patch_center_depth_km(const QuickFiniteFaultRow *r,
                                            float center_global_dn_cells,
                                            float km_per_cell) {
    return quick_ff_depth_from_global_down_dip_cells(r, center_global_dn_cells, km_per_cell);
}
static float quick_ff_patch_projected_width_cells(const QuickFiniteFaultRow *r, float km_per_cell) {
    float dip=(r?clampf_gui(r->dip_deg,5.0f,60.0f):g_fault_dip_deg);
    if(km_per_cell<0.01f) km_per_cell=0.01f;
    return fmaxf(1.0f,(current_fault_width_km()*fault_dip_cos_deg(dip))/km_per_cell);
}

static float quick_ff_origin_dip_deg(void) {
    if (g_quick_ff_grid_rows > QUICK_FF_ORIGIN_ROW && g_quick_ff_grid_cols > QUICK_FF_ORIGIN_COL) {
        int idx = quick_ff_index(QUICK_FF_ORIGIN_ROW, QUICK_FF_ORIGIN_COL);
        if (idx >= 0 && idx < g_quick_ff_count) {
            return clampf_gui(g_quick_ff_rows[idx].dip_deg, 5.0f, 60.0f);
        }
    }
    return clampf_gui(g_fault_dip_deg, 5.0f, 60.0f);
}

static int quick_ff_find_epicenter_cell(int *out_row, int *out_col, float *out_rel_s, float *out_rel_d) {
    /* v124: placement epicenter is no longer hard-wired to C3.
       Use the active delay-0 cell as the rupture origin/epicenter.  If several
       cells have delay 0, use their centroid.  Fall back to C3, then first
       active cell, only when no delay-0 active cell exists. */
    float sum_row = 0.0f, sum_col = 0.0f, sum_rel_s = 0.0f, sum_rel_d = 0.0f;
    int n = 0;
    for (int row = 0; row < g_quick_ff_grid_rows; ++row) {
        for (int col = 0; col < g_quick_ff_grid_cols; ++col) {
            int idx = quick_ff_index(row, col);
            if (idx < 0 || idx >= g_quick_ff_count) continue;
            QuickFiniteFaultRow *r = &g_quick_ff_rows[idx];
            if (!r->enabled || r->slip_m <= 0.0f || r->delay_s != 0) continue;
            sum_row += (float)row;
            sum_col += (float)col;
            sum_rel_s += r->rel_s;
            sum_rel_d += r->rel_d;
            n++;
        }
    }
    if (n > 0) {
        if (out_row) *out_row = clampi_gui((int)lroundf(sum_row / (float)n), 0, g_quick_ff_grid_rows - 1);
        if (out_col) *out_col = clampi_gui((int)lroundf(sum_col / (float)n), 0, g_quick_ff_grid_cols - 1);
        if (out_rel_s) *out_rel_s = sum_rel_s / (float)n;
        if (out_rel_d) *out_rel_d = sum_rel_d / (float)n;
        return 1;
    }

    int c3 = quick_ff_index(QUICK_FF_ORIGIN_ROW, QUICK_FF_ORIGIN_COL);
    if (c3 >= 0 && c3 < g_quick_ff_count && g_quick_ff_rows[c3].enabled && g_quick_ff_rows[c3].slip_m > 0.0f) {
        if (out_row) *out_row = QUICK_FF_ORIGIN_ROW;
        if (out_col) *out_col = QUICK_FF_ORIGIN_COL;
        if (out_rel_s) *out_rel_s = g_quick_ff_rows[c3].rel_s;
        if (out_rel_d) *out_rel_d = g_quick_ff_rows[c3].rel_d;
        return 1;
    }
    for (int row = 0; row < g_quick_ff_grid_rows; ++row) {
        for (int col = 0; col < g_quick_ff_grid_cols; ++col) {
            int idx = quick_ff_index(row, col);
            if (idx < 0 || idx >= g_quick_ff_count) continue;
            QuickFiniteFaultRow *r = &g_quick_ff_rows[idx];
            if (!r->enabled || r->slip_m <= 0.0f) continue;
            if (out_row) *out_row = row;
            if (out_col) *out_col = col;
            if (out_rel_s) *out_rel_s = r->rel_s;
            if (out_rel_d) *out_rel_d = r->rel_d;
            return 1;
        }
    }
    if (out_row) *out_row = QUICK_FF_ORIGIN_ROW;
    if (out_col) *out_col = QUICK_FF_ORIGIN_COL;
    if (out_rel_s) *out_rel_s = 0.0f;
    if (out_rel_d) *out_rel_d = 0.0f;
    return 0;
}

static float quick_ff_layout_width_cells(float km_per_cell) {
    if (km_per_cell < 0.01f) km_per_cell = 0.01f;
    /* Use one common grid-row spacing for the Excel-style layout.  Individual
       patch rectangles may still use their own dip-projected width, but their
       centers should not shear or squash just because a row has a different dip. */
    return fmaxf(1.0f, (current_fault_width_km() * fault_dip_cos_deg(quick_ff_origin_dip_deg())) / km_per_cell);
}

static void quick_ff_adjust_width_only(float delta_km) {
    float old_width = current_fault_width_km();
    float new_width = normalized_fault_width_km(old_width + delta_km);
    set_fault_width_km_value(new_width);
    set_status("Quick FF placement: patch width %.1f km, length %.1f km. Width changed only; J/K changes length.",
               current_fault_width_km(), g_fault_stamp_length_km);
}
static void quick_ff_draw_orientation_symbol(HDC hdc, float cx, float cy, float sx, float sy, float nx, float ny, float len_cells) {
    int sx1=grid_to_screen_x_float(cx-sx*len_cells*0.55f);
    int sy1=grid_to_screen_y_float(cy-sy*len_cells*0.55f);
    int sx2=grid_to_screen_x_float(cx+sx*len_cells*0.55f);
    int sy2=grid_to_screen_y_float(cy+sy*len_cells*0.55f);
    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_cells*0.46f);
    int nyp=grid_to_screen_y_float(cy+ny*len_cells*0.46f);
    HPEN strike_pen=CreatePen(PS_SOLID,3,RGB(255,220,40));
    HPEN old=(HPEN)SelectObject(hdc,strike_pen);
    MoveToEx(hdc,sx1,sy1,NULL); LineTo(hdc,sx2,sy2);
    SelectObject(hdc,old); DeleteObject(strike_pen);
    HPEN dip_pen=CreatePen(PS_SOLID,3,RGB(80,170,255));
    old=(HPEN)SelectObject(hdc,dip_pen);
    MoveToEx(hdc,cxp,cyp,NULL); LineTo(hdc,nxp,nyp);
    float dx=(float)(nxp-cxp), dy=(float)(nyp-cyp); float plen=sqrtf(dx*dx+dy*dy); if(plen<1.0f) plen=1.0f;
    float ux=dx/plen, uy=dy/plen, px=-uy, py=ux, ah=8.0f;
    MoveToEx(hdc,nxp,nyp,NULL); LineTo(hdc,(int)((float)nxp-ux*ah+px*ah*0.55f),(int)((float)nyp-uy*ah+py*ah*0.55f));
    MoveToEx(hdc,nxp,nyp,NULL); LineTo(hdc,(int)((float)nxp-ux*ah-px*ah*0.55f),(int)((float)nyp-uy*ah-py*ah*0.55f));
    SelectObject(hdc,old); DeleteObject(dip_pen);
}
static void quick_ff_draw_preview(HDC hdc) {
    if(!g_quick_ff_place_mode || g_mouse_grid_x<0 || g_mouse_grid_y<0) return;
    HPEN pen=CreatePen(PS_SOLID,2,RGB(255,0,220)); HGDIOBJ old=SelectObject(hdc,pen);
    HBRUSH hollow=(HBRUSH)GetStockObject(HOLLOW_BRUSH); HGDIOBJ oldb=SelectObject(hdc,hollow);
    float rad=normalize_deg_gui(g_fault_strike_deg)*(TSUNAMI_PI/180.0f); float sx=sinf(rad), sy=cosf(rad), nx=cosf(rad), ny=-sinf(rad);
    float km=local_ground_km_per_cell_mercator((float)g_mouse_grid_y); if(km<0.01f) km=map_center_ground_km_per_cell();
    float len_cells=fmaxf(1.0f,g_fault_stamp_length_km/km);
    float layout_wid_cells=quick_ff_layout_width_cells(km);
    float epi_rel_s=0.0f, epi_rel_d=0.0f; int epi_row=QUICK_FF_ORIGIN_ROW, epi_col=QUICK_FF_ORIGIN_COL;
    quick_ff_find_epicenter_cell(&epi_row,&epi_col,&epi_rel_s,&epi_rel_d);
    for(int i=0;i<g_quick_ff_count;++i){
        QuickFiniteFaultRow *r=&g_quick_ff_rows[i]; if(!r->enabled || r->slip_m<=0.0f) continue;
        float row_wid_cells=quick_ff_patch_projected_width_cells(r,km);
        float rel_s=r->rel_s-epi_rel_s;
        float rel_d=r->rel_d-epi_rel_d;
        float cx=(float)g_mouse_grid_x+rel_s*len_cells*sx+rel_d*layout_wid_cells*nx;
        float cy=(float)g_mouse_grid_y+rel_s*len_cells*sy+rel_d*layout_wid_cells*ny;
        POINT pts[4]; quick_ff_rect_corners(cx,cy,0.5f*len_cells,0.5f*row_wid_cells,sx,sy,nx,ny,pts); Polygon(hdc,pts,4);
    }
    quick_ff_draw_orientation_symbol(hdc,(float)g_mouse_grid_x,(float)g_mouse_grid_y,sx,sy,nx,ny,fmaxf(12.0f,len_cells));
    int ex=grid_to_screen_x(g_mouse_grid_x), ey=grid_to_screen_y(g_mouse_grid_y); MoveToEx(hdc,ex-8,ey-8,NULL); LineTo(hdc,ex+8,ey+8); MoveToEx(hdc,ex-8,ey+8,NULL); LineTo(hdc,ex+8,ey-8);
    SelectObject(hdc,oldb); SelectObject(hdc,old); DeleteObject(pen);
}
static int quick_ff_apply_at(HWND hwnd,int epic_x,int epic_y) {
    if(epic_x<0||epic_y<0) return 0; source_undo_push("quick finite-fault grid apply");
    int old_active_fault=g_active_stamp_fault_id, old_active_patch=g_active_stamp_patch_id; float old_dip=g_fault_dip_deg;
    int fault_id=g_next_quick_ff_fault_id++;
    float rad=normalize_deg_gui(g_fault_strike_deg)*(TSUNAMI_PI/180.0f); float sx=sinf(rad), sy=cosf(rad), nx=cosf(rad), ny=-sinf(rad);
    float km=local_ground_km_per_cell_mercator((float)epic_y); if(km<0.01f) km=map_center_ground_km_per_cell();
    float len_cells=fmaxf(1.0f,g_fault_stamp_length_km/km);
    float layout_wid_cells=quick_ff_layout_width_cells(km);
    float epi_rel_s=0.0f, epi_rel_d=0.0f; int epi_row=QUICK_FF_ORIGIN_ROW, epi_col=QUICK_FF_ORIGIN_COL;
    quick_ff_find_epicenter_cell(&epi_row,&epi_col,&epi_rel_s,&epi_rel_d);
    int changed=0, patch_count=0;
    for(int i=0;i<g_quick_ff_count;++i){
        QuickFiniteFaultRow *r=&g_quick_ff_rows[i]; if(!r->enabled||r->slip_m<=0.0f) continue;
        patch_count++;
        float row_wid_cells=quick_ff_patch_projected_width_cells(r,km);
        float rel_s = r->rel_s - epi_rel_s;
        float rel_d = r->rel_d - epi_rel_d;
        float center_global_dn_cells = rel_d * layout_wid_cells;
        float cx=(float)epic_x+rel_s*len_cells*sx+center_global_dn_cells*nx;
        float cy=(float)epic_y+rel_s*len_cells*sy+center_global_dn_cells*ny;
        /* Rebuild injection height from this patch's slip, dip, and map-consistent
           down-dip distance from the delay-0 epicenter.  v110 uses one global down-dip
           coordinate for both row offset and local-in-patch offset, so ,/. width
           edits do not create a separate stretched/compressed depth field. */
        float patch_center_depth_km=quick_ff_patch_center_depth_km(r, center_global_dn_cells, km);
        int sr=(int)ceilf(sqrtf(len_cells*len_cells+row_wid_cells*row_wid_cells)+2.0f);
        g_active_stamp_fault_id=fault_id; g_active_stamp_patch_id=i+1; g_fault_dip_deg=r->dip_deg;
        for(int yy=(int)floorf(cy)-sr; yy<=(int)ceilf(cy)+sr; ++yy){ if(yy<0||yy>=(int)g_grid.height) continue; for(int xx=(int)floorf(cx)-sr; xx<=(int)ceilf(cx)+sr; ++xx){ if(xx<0||xx>=(int)g_grid.width) continue; float ds=((float)xx-cx)*sx+((float)yy-cy)*sy; float dn=((float)xx-cx)*nx+((float)yy-cy)*ny; if(fabsf(ds)<=0.5f*len_cells && fabsf(dn)<=0.5f*row_wid_cells){ float global_dn_cells=center_global_dn_cells+dn; float cell_depth_km=quick_ff_depth_from_global_down_dip_cells(r,global_dn_cells,km); float h=preset_recompute_height_from_slip_depth(r->slip_m,r->dip_deg,cell_depth_km); if(set_source_cell_height_force(hwnd,xx,yy,h,r->delay_s)){ int si=find_source_cell(xx,yy); if(si>=0){ SourceSpec *src=&g_sources.items[si]; size_t sidx=tsunami_index(&g_grid,(uint32_t)xx,(uint32_t)yy); src->height_m=g_grid.solid[sidx]?0.0f:h; src->slip_m=r->slip_m; src->fault_id=fault_id; src->fault_patch_id=i+1; src->stamp_strike_deg=g_fault_strike_deg; src->stamp_dip_deg=r->dip_deg; src->stamp_depth_km=cell_depth_km; src->stamp_width_km=current_fault_width_km(); src->stamp_length_km=g_fault_stamp_length_km; } changed++; } } } }
        (void)patch_center_depth_km;
    }
    g_active_stamp_fault_id=old_active_fault; g_active_stamp_patch_id=old_active_patch; g_fault_dip_deg=old_dip; reset_state_preserve_sources(); set_status("Quick finite-fault grid applied at delay-0 epicenter x=%d y=%d: %d source cell(s), %d patch cell(s). Epicenter cell=%s%d. Depth is computed from the displayed down-dip axis relative to that delay-0 cell.",epic_x,epic_y,changed,patch_count,quick_ff_col_name(epi_col),epi_row+1); return changed;
}
static int quick_ff_handle_key(HWND hwnd, WPARAM key) {
    switch(key){
    case VK_ESCAPE: g_quick_ff_place_mode=0; set_status("Quick finite-fault placement cancelled."); return 1;
    case VK_RETURN: if(g_mouse_grid_x>=0&&g_mouse_grid_y>=0){ quick_ff_apply_at(hwnd,g_mouse_grid_x,g_mouse_grid_y); g_quick_ff_place_mode=0; } return 1;
    case 'S': open_quick_finite_fault_editor(hwnd); return 1;
    case 'Z': g_fault_strike_deg=normalize_deg_gui(g_fault_strike_deg-strike_rotation_key_step_deg()); set_status("Quick FF placement: strike %.0f deg.",g_fault_strike_deg); return 1;
    case 'X': g_fault_strike_deg=normalize_deg_gui(g_fault_strike_deg+strike_rotation_key_step_deg()); set_status("Quick FF placement: strike %.0f deg.",g_fault_strike_deg); return 1;
    case 'J': g_fault_stamp_length_km=normalized_stamp_length_km(g_fault_stamp_length_km-stamp_length_key_step_km()); set_status("Quick FF placement: patch length %.1f km.",g_fault_stamp_length_km); return 1;
    case 'K': g_fault_stamp_length_km=normalized_stamp_length_km(g_fault_stamp_length_km+stamp_length_key_step_km()); set_status("Quick FF placement: patch length %.1f km.",g_fault_stamp_length_km); return 1;
    case VK_OEM_COMMA: quick_ff_adjust_width_only(-fault_width_key_step_km()); return 1;
    case VK_OEM_PERIOD: quick_ff_adjust_width_only(fault_width_key_step_km()); return 1;
    }
    return 0;
}

static void settings_handle_petal_hscroll(HWND hwnd, WPARAM wparam, LPARAM lparam) {
    HWND ctrl = (HWND)lparam;
    if (!ctrl) return;
    int id = GetDlgCtrlID(ctrl);
    float *value = NULL;
    float min_v = 0.0f, max_v = 1.0f;
    const char *name = "Petal slider";
    if (!settings_petal_slider_info(id, &value, &min_v, &max_v, &name) || !value) return;

    int min_p = petal_slider_pos(min_v);
    int max_p = petal_slider_pos(max_v);
    int pos = GetScrollPos(ctrl, SB_CTL);
    switch (LOWORD(wparam)) {
    case SB_LINELEFT: pos -= 1; break;
    case SB_LINERIGHT: pos += 1; break;
    case SB_PAGELEFT: pos -= 10; break;
    case SB_PAGERIGHT: pos += 10; break;
    case SB_THUMBPOSITION:
    case SB_THUMBTRACK:
        pos = HIWORD(wparam);
        break;
    case SB_LEFT: pos = min_p; break;
    case SB_RIGHT: pos = max_p; break;
    default:
        break;
    }
    if (pos < min_p) pos = min_p;
    if (pos > max_p) pos = max_p;
    SetScrollPos(ctrl, SB_CTL, pos, TRUE);
    *value = petal_slider_value(pos);
    char msg[192];
    snprintf(msg, sizeof(msg), "%s = %.2fx. Press Regenerate or G to apply to secondary deformation.", name, *value);
    settings_changed(msg);
}

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_HSCROLL:
        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_quick_ff_place_mode) {
                g_hover_station_i = -1;
                set_status("Quick finite-fault grid preview: C3 epicenter x=%d y=%d | strike %.0f deg | length %.1f km | width %.1f km | click/Enter apply.", gx, gy, g_fault_strike_deg, g_fault_stamp_length_km, current_fault_width_km());
            } else 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_quick_ff_place_mode) {
            if (screen_to_grid(mx, my, &gx, &gy)) {
                g_mouse_grid_x = gx;
                g_mouse_grid_y = gy;
                quick_ff_apply_at(hwnd, gx, gy);
                g_quick_ff_place_mode = 0;
            } else {
                set_status("Quick finite-fault placement: click inside the map, or Esc to cancel.");
            }
            InvalidateRect(hwnd, NULL, FALSE);
            return 0;
        }

        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 ((GetKeyState(VK_CONTROL) & 0x8000) && wparam == 'U') {
            save_finite_fault_usgs_png_dialog(hwnd);
            return 0;
        }
        if (g_quick_ff_place_mode) {
            if (quick_ff_handle_key(hwnd, wparam)) {
                settings_refresh();
                InvalidateRect(hwnd, NULL, FALSE);
                return 0;
            }
        } else 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 = 0;
            g_drag_painting = 0;
            g_pan_dragging = 0;
            open_quick_finite_fault_editor(hwnd);
            set_status("Opened quick finite-fault editor. Closing it starts mouse-following epicenter placement preview.");
            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_quick_ff_hwnd) DestroyWindow(g_quick_ff_hwnd);
        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;
}
