#!/usr/bin/perl
# vramgaze3.pl - Visualize AMD GPU VRAM allocations from amdgpu debugfs
# Gtk3 port of vramgaze.pl
#
# Usage: sudo perl vramgaze3.pl
#        sudo perl vramgaze3.pl --dri 1
#
# Supports vramgaze_buddy kernel module for per-block data on Linux 6.x systems.
# Build and load the module first:
#   make && sudo insmod vramgaze_buddy.ko
# Then run this script as normal.

use strict;
use warnings;
use Glib qw/TRUE FALSE/;
use Gtk3 '-init';
use Cairo;
use POSIX qw();
use File::Basename;
use File::Temp qw/tempfile/;
use Fcntl qw(SEEK_SET O_RDONLY);
use PDL;
use File::Spec;
use File::Path qw(make_path);
use Cwd qw(cwd);
use Time::HiRes qw(time);

sub _cairo_set_source_pixbuf {
    my ($cr, $pixbuf, $x, $y) = @_;
    my $w = $pixbuf->get_width;
    my $h = $pixbuf->get_height;
    return unless $w > 0 && $h > 0;
    my $pdata     = $pixbuf->get_pixels;
    my $rowstride = $pixbuf->get_rowstride;
    my $has_alpha = $pixbuf->get_has_alpha;
    my $surface   = Cairo::ImageSurface->create('argb32', $w, $h);
    my $s_stride  = $surface->get_stride;
    my $s_data    = $surface->get_data;
    for my $row (0 .. $h-1) {
        my $src_offset = $row * $rowstride;
        my $dst_offset = $row * $s_stride;
        if ($has_alpha) {
            my $row_data = substr($pdata, $src_offset, $w * 4);
            my @bytes = unpack 'C*', $row_data;
            my @bgra;
            for (my $i = 0; $i < @bytes; $i += 4) {
                push @bgra, $bytes[$i+2], $bytes[$i+1], $bytes[$i], $bytes[$i+3];
            }
            substr($s_data, $dst_offset, $w * 4) = pack 'C*', @bgra;
        } else {
            my $row_data = substr($pdata, $src_offset, $w * 3);
            my @bytes = unpack 'C*', $row_data;
            my @bgra;
            for (my $i = 0; $i < @bytes; $i += 3) {
                push @bgra, $bytes[$i+2], $bytes[$i+1], $bytes[$i], 255;
            }
            substr($s_data, $dst_offset, $w * 4) = pack 'C*', @bgra;
        }
    }
    $surface->mark_dirty;
    $cr->set_source_surface($surface, $x, $y);
    $cr->paint;
}

# ---------------------------------------------------------------------------
# CLI args  (must declare vars before the loop that assigns them)
# ---------------------------------------------------------------------------
my $DRI_INDEX    = 0;
my $ASIC         = 'unknown';
my $VRAM_FILE    = undef;
my $GEM_FILE     = undef;
my $DRI_EXPLICIT = 0;

for (my $i = 0; $i < @ARGV; $i++) {
    if    ($ARGV[$i] eq '--dri'  && defined $ARGV[$i+1]) { $DRI_INDEX = $ARGV[++$i]; $DRI_EXPLICIT = 1; }
    elsif ($ARGV[$i] eq '--asic' && defined $ARGV[$i+1]) { $ASIC      = $ARGV[++$i]; }
    elsif ($ARGV[$i] eq '--vram' && defined $ARGV[$i+1]) { $VRAM_FILE = $ARGV[++$i]; }
    elsif ($ARGV[$i] eq '--gem'  && defined $ARGV[$i+1]) { $GEM_FILE  = $ARGV[++$i]; }
}

# ---------------------------------------------------------------------------
# Debug logger
# ---------------------------------------------------------------------------
sub debug {
    my ($msg) = @_;
    my @t = localtime;
    warn sprintf("[%02d:%02d:%02d] %s\n", $t[2], $t[1], $t[0], $msg);
}

# ---------------------------------------------------------------------------
# Auto-detect DRI index (scan for first amdgpu debugfs node)
# ---------------------------------------------------------------------------
sub detect_dri_index {
    for my $i (0, 1, 2, 3, 128, 129) {
        my $mm = "/sys/kernel/debug/dri/$i/amdgpu_vram_mm";
        if (-r $mm) {
            debug("Auto-detected amdgpu at dri=$i");
            return $i;
        }
    }
    debug("No amdgpu debugfs found, defaulting to dri=0");
    return 0;
}

$DRI_INDEX = detect_dri_index() unless $DRI_EXPLICIT;

# ---------------------------------------------------------------------------
# ASIC Auto-detection  (runs after DRI_INDEX is finalised)
# ---------------------------------------------------------------------------
sub detect_asic {
    my ($dri_idx) = @_;
    my $asic_path   = "/sys/class/drm/card$dri_idx/device/asic_name";
    my $pcidev_path = "/sys/class/drm/card$dri_idx/device/device";
    if (-r $asic_path) {
        open my $fh, '<', $asic_path or return 'unknown';
        my $name = <$fh>; close $fh; chomp $name;
        return $name if $name && $name =~ /^[a-zA-Z0-9]+$/;
    }
    if (-r $pcidev_path) {
        open my $fh, '<', $pcidev_path or return 'unknown';
        my $devid = <$fh>; close $fh; chomp $devid;
        $devid = lc($devid); $devid =~ s/^0x//;
        my %pci_map = (
            '67df' => 'polaris10', '67ef' => 'polaris11', '6980' => 'polaris12',
            '6981' => 'polaris12', '687f' => 'vega10',    '69af' => 'vega20',
            '7310' => 'navi10',    '7312' => 'navi14',    '73bf' => 'navi21',
            '73a5' => 'navi22',    '73e0' => 'navi23',    '6611' => 'oland',
        );
        return $pci_map{$devid} if exists $pci_map{$devid};
        return "pci_$devid";
    }
    return 'unknown';
}

$ASIC = detect_asic($DRI_INDEX) unless $ASIC ne 'unknown';
debug("Detected ASIC: $ASIC");

my $VRAM_MM_PATH     = "/sys/kernel/debug/dri/$DRI_INDEX/amdgpu_vram_mm";
my $GEM_INFO_PATH    = "/sys/kernel/debug/dri/$DRI_INDEX/amdgpu_gem_info";
my $AMDGPU_VRAM_PATH = "/sys/kernel/debug/dri/$DRI_INDEX/amdgpu_vram";

# Path to the vramgaze_buddy kernel module's debugfs output
my $BUDDY_NODES_PATH = "/sys/kernel/debug/vramgaze_buddy_nodes";

# ---------------------------------------------------------------------------
# vramgaze_buddy module detection
# ---------------------------------------------------------------------------

# Returns true if the buddy module is loaded and readable
sub buddy_module_available {
    return -r $BUDDY_NODES_PATH;
}

# ---------------------------------------------------------------------------
# Global state
# ---------------------------------------------------------------------------
my $state = {
    regions             => [],
    total_pages         => 0,
    used_pages          => 0,
    free_pages          => 0,
    vram_size_bytes     => 0,
    map_areas           => [],
    hilbert_window_ref  => undef,
    update_timer_id     => undef,
    update_interval_sec => 2,
    statusbar           => undef,
    status_context      => undef,
    drawing_area        => undef,
    read_method         => 'none',
    gem_data            => {},
    gem_map_by_size     => {},
    range_start_offset  => undef,
    range_end_offset    => undef,
    range_anchor_offset => undef,
    chk_snap            => undef,
    chk_gtt             => undef,
    hilbert_state       => undef,
    gem_strip_visible   => 0,
    gem_strip_areas     => [],
    gtt_by_pid          => {},
    gtt_total           => 0,
    gtt_map_areas       => [],
    using_buddy_module  => 0,   # set 1 when vramgaze_buddy is providing region data
};

my %C = (
    used    => [0.13, 0.67, 1.0 ], free    => [0.07, 0.20, 0.07],
    bg      => [0.10, 0.10, 0.10], grid    => [0.20, 0.20, 0.20],
    hi      => [1.0,  0.80, 0.0 ], text_fg => [0.90, 0.90, 0.90],
);

sub _parse_rgba {
    my ($str) = @_; my $c = Gtk3::Gdk::RGBA->new; $c->parse($str); return $c;
}

# ---------------------------------------------------------------------------
# VRAM reading via AMDGPU debugfs
# ---------------------------------------------------------------------------
sub read_vram {
    my ($offset, $size) = @_;
    if (! -r $AMDGPU_VRAM_PATH) {
        debug("amdgpu_vram file not readable: $AMDGPU_VRAM_PATH (run as root)");
        $state->{read_method} = 'none';
        return undef;
    }
    $size = 256 * 1024 * 1024 if $size > 256 * 1024 * 1024;
    debug(sprintf("debugfs: reading %s from %s at +0x%x", format_bytes($size), $AMDGPU_VRAM_PATH, $offset));

    sysopen my $fd, $AMDGPU_VRAM_PATH, O_RDONLY or do { debug("open failed: $!"); return undef; };
    sysseek $fd, $offset, SEEK_SET or do { debug("seek failed: $!"); close $fd; return undef; };

    my $buf = ''; my $total = 0;
    while ($total < $size) {
        my $chunk = ($size - $total) > 67108864 ? 67108864 : ($size - $total);
        my $n = sysread $fd, $buf, $chunk, $total;
        if (!defined $n) { debug("sysread failed: $!"); last; }
        last if $n == 0;
        $total += $n;
    }
    close $fd;
    if ($total > 0) { $state->{read_method} = 'debugfs'; return \substr($buf, 0, $total); }
    return undef;
}

# ---------------------------------------------------------------------------
# DRM reading via /proc/ (GTT mapped in system ram)
# ---------------------------------------------------------------------------
sub read_drm_memory_for_pid {
    my ($pid) = @_;
    my $maps_file = "/proc/$pid/maps";
    if (! -r $maps_file) { debug("read_drm_memory_for_pid: cannot read $maps_file"); return undef; }

    open my $fh, '<', $maps_file or return undef;
    my @ranges;
    {
        no warnings 'portable';
        while(my $line = <$fh>) {
            chomp $line;
            if ($line =~ /^([0-9a-f]+)-([0-9a-f]+)\s+([r-][w-][x-][ps])\s+([0-9a-f]+)\s+[\d:]+\s+\d+\s*(.*)$/) {
                my ($start, $end, $perms, $offset, $path) = (hex($1), hex($2), $3, hex($4), $5);
                next unless $perms =~ /^r/;
                if ($perms =~ /s$/ || $path =~ /(dri|drm|amdgpu|kfd|radeon)/i) {
                    push @ranges, { start=>$start, end=>$end, size=>$end-$start, perms=>$perms, path=>$path };
                }
            }
        }
    }
    close $fh;

    if (!@ranges) { debug("read_drm_memory_for_pid: No matching DRM/shared mappings found for PID $pid"); return undef; }

    my $mem_file = "/proc/$pid/mem";
    sysopen my $mfh, $mem_file, O_RDONLY or do { debug("read_drm_memory_for_pid: cannot open $mem_file"); return undef; };

    my $buf = ""; my @map_info; my $offset_in_image = 0;
    my $global_max_read = 1024 * 1024 * 1024; my $total_read = 0;

    for my $r (@ranges) {
        my $seek_res = sysseek($mfh, $r->{start}, SEEK_SET);
        next unless defined $seek_res;
        my $range_buf = ""; my $size = $r->{size};
        if ($total_read + $size > $global_max_read) { $size = $global_max_read - $total_read; }
        last if $size <= 0;
        my $bytes_read = sysread($mfh, $range_buf, $size);
        if (defined $bytes_read && $bytes_read > 0) {
            $buf .= $range_buf;
            push @map_info, {
                offset_in_image => $offset_in_image, length_in_image => $bytes_read,
                path  => $r->{path} || "[anonymous]", size  => $bytes_read,
                perms => $r->{perms}, start => $r->{start}, end => $r->{start} + $bytes_read - 1,
                status => 'used', idx => $pid
            };
            $offset_in_image += $bytes_read; $total_read += $bytes_read;
        }
    }
    close $mfh;

    if ($offset_in_image > 0) {
        debug(sprintf("read_drm_memory_for_pid: Successfully read %s from PID %d", format_bytes($offset_in_image), $pid));
        return [ \$buf, \@map_info ];
    }
    debug("read_drm_memory_for_pid: Reads returned 0 bytes.");
    return undef;
}

sub read_method_label {
    if ($state->{using_buddy_module}) {
        return "vramgaze_buddy module (per-block)";
    }
    return (-r $AMDGPU_VRAM_PATH) ? "debugfs (amdgpu_vram)" : "no read access (run as root)";
}

sub get_indices_for_offsets {
    my ($start_offset, $end_offset) = @_;
    my $s_idx = 0; my $e_idx = $#{$state->{regions}};
    for my $i (0 .. $#{$state->{regions}}) {
        my $r_s = $state->{regions}[$i]{start} * 4096;
        my $r_e = ($state->{regions}[$i]{end} + 1) * 4096;
        if ($start_offset >= $r_s && $start_offset < $r_e) { $s_idx = $i; }
        if ($end_offset > $r_s && $end_offset <= $r_e) { $e_idx = $i; }
    }
    return ($s_idx, $e_idx);
}

sub build_multi_region_map_info {
    my ($start_idx, $end_idx, $base_offset, $read_size) = @_;
    my @map_info;
    for my $i ($start_idx .. $end_idx) {
        my $r = $state->{regions}[$i];
        my $r_start_byte  = $r->{start} * 4096;
        my $r_end_byte    = ($r->{end} + 1) * 4096;
        my $overlap_start = $r_start_byte > $base_offset ? $r_start_byte : $base_offset;
        my $overlap_end   = $r_end_byte < ($base_offset + $read_size) ? $r_end_byte : ($base_offset + $read_size);
        if ($overlap_start < $overlap_end) {
            push @map_info, {
                offset_in_image => $overlap_start - $base_offset,
                length_in_image => $overlap_end - $overlap_start,
                path   => "Region $i", size => $r->{size}, perms => "rw-",
                start  => $r->{start}, end  => $r->{end},
                status => $r->{status}, idx => $i
            };
        }
    }
    return \@map_info;
}

sub create_pixbuf_from_data {
    my ($raw_data_ref) = @_;
    my $total_bytes = length($$raw_data_ref);
    return undef if $total_bytes < 3;
    my $total_pixels = POSIX::floor($total_bytes / 3);
    my $width = POSIX::floor(sqrt($total_pixels));
    return undef if $width == 0;
    my $height = POSIX::floor($total_pixels / $width);
    return undef if $height == 0;
    my $bytes_to_use = $width * $height * 3;
    my $image_data = substr($$raw_data_ref, 0, $bytes_to_use);
    debug(sprintf("Saving Full Image: Creating Pixbuf %d x %d (%.2f MB)...",
        $width, $height, $bytes_to_use / (1024*1024)));
    my $pixbuf = eval {
        Gtk3::Gdk::Pixbuf->new_from_data($image_data, 'rgb', FALSE, 8, $width, $height, $width * 3);
    };
    if ($@ || !$pixbuf) { debug("create_pixbuf_from_data failed: $@"); }
    else { $pixbuf->{_raw_data_ref} = \$image_data; debug("create_pixbuf_from_data succeeded."); }
    return $pixbuf;
}

# ---------------------------------------------------------------------------
# VRAM Image Popup (Advanced Analysis Window)
# ---------------------------------------------------------------------------
sub show_vram_image_popup {
    my ($parent, $raw_ref, $title_info, $map_info) = @_;

    my $popup = Gtk3::Window->new('toplevel');
    $popup->set_title(sprintf("VRAM Image - %s  [via %s]", $title_info->{title}, $state->{read_method}));
    $popup->set_default_size(1100, 750);
    $popup->set_position('center-on-parent');

    my $vbox = Gtk3::Box->new('vertical', 5);
    $vbox->set_border_width(5);
    $popup->add($vbox);

    my $popup_state = {
        raw_data_ref              => $raw_ref,
        loaded_raw_data_ref       => undef,
        virtual_width             => 0,
        virtual_height            => 0,
        image_map_info            => $map_info,
        timer_id                  => undef,
        diff_points               => undef,
        diff_toggle_button        => undef,
        overlay_map_areas         => [],
        drawing_area              => undef,
        scrolled_window           => undef,
        update_toggle_button      => undef,
        overlay_toggle_button     => undef,
        star_info                 => undef,
        star_timer_id             => undef,
        sound_player_pid          => undef,
        sound_child_watch_id      => undef,
        sound_temp_file           => undef,
        _is_changing_sound_button => FALSE,
        update_baseline_ref       => undef,
        selection_start_offset    => undef,
        selection_end_offset      => undef,
        selection_highlight_rects => [],
        selection_toggle_button   => undef,
        start_offset_entry        => undef,
        end_offset_entry          => undef,
        extra_selection_widgets   => [],
        thumbnail_window_ref      => undef,
        infobar_labels            => {},
        pname                     => $title_info->{title},
        pid                       => $title_info->{pid},
        status_indicator_da       => undef,
        status_is_green           => FALSE,
    };
    $popup_state->{pname} =~ s/ /_/g;

    my $toolbar1 = Gtk3::Box->new('horizontal', 5);
    my $toolbar2 = Gtk3::Box->new('horizontal', 5);
    my $toolbar3 = Gtk3::Box->new('horizontal', 5);
    my $toolbar4 = Gtk3::Box->new('horizontal', 5);

    my $btn_save_png  = Gtk3::Button->new_with_label("Save PNG");
    my $btn_save_raw  = Gtk3::Button->new_with_label("Save RAM Raw");
    my $btn_load_raw  = Gtk3::Button->new_with_label("Load RAM Raw");
    my $status_box    = Gtk3::DrawingArea->new;
    $status_box->set_size_request(20, 20);
    $popup_state->{status_indicator_da} = $status_box;
    $status_box->signal_connect(draw => sub {
        my ($widget, $cr) = @_;
        if ($popup_state->{status_is_green}) { $cr->set_source_rgb(0, 0.8, 0); }
        else { $cr->set_source_rgb(0.9, 0, 0); }
        $cr->paint; return TRUE;
    });

    my $diff_button   = Gtk3::Button->new_with_label("Diff");
    my $diff_toggle   = Gtk3::CheckButton->new_with_label("Show Diffs");
    $popup_state->{diff_toggle_button} = $diff_toggle;
    my $strings_button  = Gtk3::Button->new_with_label("Strings");
    my $sound_button    = Gtk3::ToggleButton->new_with_label("Sound");
    my $save_wav_button = Gtk3::Button->new_with_label("Save wav");

    $toolbar1->pack_start($btn_save_png,   FALSE, FALSE, 0);
    $toolbar1->pack_start($btn_save_raw,   FALSE, FALSE, 5);
    $toolbar1->pack_start($btn_load_raw,   FALSE, FALSE, 5);
    $toolbar1->pack_start($status_box,     FALSE, FALSE, 5);
    $toolbar1->pack_start($diff_button,    FALSE, FALSE, 0);
    $toolbar1->pack_start($diff_toggle,    FALSE, FALSE, 5);
    $toolbar1->pack_start($strings_button, FALSE, FALSE, 5);
    $toolbar1->pack_start($sound_button,   FALSE, FALSE, 0);
    $toolbar1->pack_start($save_wav_button,FALSE, FALSE, 5);

    my $width_label = Gtk3::Label->new("Width:");
    my $width_adj   = Gtk3::Adjustment->new(0, 4, 32768, 1, 64, 0);
    my $width_spin  = Gtk3::SpinButton->new($width_adj, 0, 0);
    $width_spin->set_width_chars(5);
    my $min_label   = Gtk3::Label->new("Min:");
    my $min_entry   = Gtk3::Entry->new; $min_entry->set_width_chars(4); $min_entry->set_text(4);
    my $max_label   = Gtk3::Label->new("Max:");
    my $max_entry   = Gtk3::Entry->new; $max_entry->set_width_chars(5); $max_entry->set_text(2048);
    my $width_scale = Gtk3::Scale->new('horizontal', $width_adj);
    $width_scale->set_draw_value(FALSE);
    my $btn_autowidth = Gtk3::Button->new_with_label("Auto (Sq)");

    my $apply_min_max = sub {
        my $new_min = $min_entry->get_text; my $new_max = $max_entry->get_text;
        $new_min = 4     unless $new_min =~ /^\d+$/ && $new_min >= 4;
        $new_max = 32768 unless $new_max =~ /^\d+$/ && $new_max >= 4;
        ($new_min, $new_max) = ($new_max, $new_min) if $new_min > $new_max;
        $min_entry->set_text($new_min); $max_entry->set_text($new_max);
        $width_adj->configure($width_adj->get_value, $new_min, $new_max + 1,
                              $width_adj->get_step_increment, $width_adj->get_page_increment,
                              $width_adj->get_page_size);
    };
    $min_entry->signal_connect(activate => $apply_min_max);
    $max_entry->signal_connect(activate => $apply_min_max);

    $toolbar2->pack_start($width_label,   FALSE, FALSE, 0);
    $toolbar2->pack_start($width_spin,    FALSE, FALSE, 0);
    $toolbar2->pack_start($min_label,     FALSE, FALSE, 2);
    $toolbar2->pack_start($min_entry,     FALSE, FALSE, 0);
    $toolbar2->pack_start($width_scale,   TRUE,  TRUE,  5);
    $toolbar2->pack_start($max_label,     FALSE, FALSE, 2);
    $toolbar2->pack_start($max_entry,     FALSE, FALSE, 0);
    $toolbar2->pack_start($btn_autowidth, FALSE, FALSE, 5);

    my $overlay_toggle = Gtk3::CheckButton->new_with_label("Overlays");
    $overlay_toggle->set_active(TRUE);
    $popup_state->{overlay_toggle_button} = $overlay_toggle;
    my $thumbnail_check = Gtk3::CheckButton->new_with_label("Thumbnail");
    $popup_state->{thumbnail_check} = $thumbnail_check;
    my $infobar_toggle  = Gtk3::CheckButton->new_with_label("Infobar");

    my $update_label  = Gtk3::Label->new("  Update(s):");
    my $update_entry  = Gtk3::Entry->new();
    $update_entry->set_text("1.0"); $update_entry->set_width_chars(4);
    my $update_toggle = Gtk3::ToggleButton->new_with_label("Start Updating");
    $popup_state->{update_toggle_button} = $update_toggle;
    my $decode_button   = Gtk3::Button->new_with_label("Bitmap Decode");
    my $digraph_button  = Gtk3::Button->new_with_label("Digraph");
    my $photorec_button = Gtk3::Button->new_with_label("photorec");

    $toolbar3->pack_start($thumbnail_check, FALSE, FALSE, 5);
    $toolbar3->pack_start($infobar_toggle,  FALSE, FALSE, 5);
    $toolbar3->pack_start($update_label,    FALSE, FALSE, 0);
    $toolbar3->pack_start($update_entry,    FALSE, FALSE, 0);
    $toolbar3->pack_start($update_toggle,   FALSE, FALSE, 5);
    $toolbar3->pack_start(Gtk3::Separator->new('vertical'), FALSE, FALSE, 5);
    $toolbar3->pack_start($decode_button,   FALSE, FALSE, 0);
    $toolbar3->pack_start($digraph_button,  FALSE, FALSE, 5);
    $toolbar3->pack_start($photorec_button, FALSE, FALSE, 5);

    my $start_label = Gtk3::Label->new("Selected Offset start:");
    my $start_entry = Gtk3::Entry->new(); $start_entry->set_width_chars(10);
    $popup_state->{start_offset_entry} = $start_entry;
    my $stop_label  = Gtk3::Label->new("stop:");
    my $end_entry   = Gtk3::Entry->new(); $end_entry->set_width_chars(10);
    $popup_state->{end_offset_entry} = $end_entry;
    my $selection_toggle = Gtk3::CheckButton->new();
    $selection_toggle->set_active(TRUE);
    $popup_state->{selection_toggle_button} = $selection_toggle;
    my $add_more_button = Gtk3::Button->new_with_label("Add more");

    $toolbar4->pack_start($start_label,      FALSE, FALSE, 0);
    $toolbar4->pack_start($start_entry,      FALSE, FALSE, 5);
    $toolbar4->pack_start($stop_label,       FALSE, FALSE, 5);
    $toolbar4->pack_start($end_entry,        FALSE, FALSE, 5);
    $toolbar4->pack_start($selection_toggle, FALSE, FALSE, 5);
    $toolbar4->pack_start($add_more_button,  FALSE, FALSE, 10);

    my $extra_selections_hbox = Gtk3::Box->new('horizontal', 5);
    $toolbar4->pack_start($extra_selections_hbox, TRUE, TRUE, 0);

    $vbox->pack_start($toolbar1, FALSE, FALSE, 0);
    $vbox->pack_start($toolbar2, FALSE, FALSE, 0);
    $vbox->pack_start($toolbar3, FALSE, FALSE, 0);
    $vbox->pack_start($toolbar4, FALSE, FALSE, 0);

    my $info_label = Gtk3::Label->new; $info_label->set_alignment(0, 0.5);
    $vbox->pack_start($info_label, FALSE, FALSE, 0);

    my $infobar_hbox     = Gtk3::Box->new('horizontal', 5);
    my $path_info_label  = Gtk3::Label->new;
    my $size_info_label  = Gtk3::Label->new;
    my $perms_info_label = Gtk3::Label->new;
    my $range_info_label = Gtk3::Label->new;

    for my $item (["Path:", $path_info_label], ["Size:", $size_info_label],
                  ["Perms:", $perms_info_label], ["Range:", $range_info_label]) {
        my $lbl = Gtk3::Label->new; $lbl->set_markup("<b>$item->[0]</b> ");
        $infobar_hbox->pack_start($lbl,       FALSE, FALSE, 5);
        $infobar_hbox->pack_start($item->[1], FALSE, FALSE, 5);
    }
    $vbox->pack_start($infobar_hbox, FALSE, FALSE, 5);
    $popup_state->{infobar_labels} = {
        path  => $path_info_label, size  => $size_info_label,
        perms => $perms_info_label, range => $range_info_label,
    };

    my $sw = Gtk3::ScrolledWindow->new(undef, undef);
    $sw->set_policy('automatic', 'automatic');
    $sw->set_property('overlay-scrolling', 0);
    my $da = Gtk3::DrawingArea->new();
    $da->set_events(['pointer-motion-mask', 'button-press-mask']);
    $da->set_size_request(1, 1);
    $sw->add($da);
    $vbox->pack_start($sw, TRUE, TRUE, 0);
    $popup_state->{drawing_area}    = $da;
    $popup_state->{scrolled_window} = $sw;

    $da->signal_connect(draw => sub {
        my ($widget, $cr) = @_;
        return FALSE unless $popup_state->{raw_data_ref};
        $cr->set_source_rgb(0.08, 0.08, 0.08); $cr->paint;
        my $vw = $popup_state->{virtual_width};
        my $vh = $popup_state->{virtual_height};
        return FALSE unless $vw > 0 && $vh > 0;

        my ($cx, $cy, $cx2, $cy2) = $cr->clip_extents;
        my $start_row = POSIX::floor($cy); my $end_row = POSIX::ceil($cy2);
        $start_row = 0  if $start_row < 0;
        $end_row   = $vh if $end_row > $vh;
        my $rows = $end_row - $start_row;

        if ($rows > 0) {
            my $bpr    = $vw * 3;
            my $offset = $start_row * $bpr;
            my $len    = $rows * $bpr;
            my $total  = length(${$popup_state->{raw_data_ref}});
            if ($offset < $total) {
                $len = $total - $offset if $offset + $len > $total;
                my $slice = substr(${$popup_state->{raw_data_ref}}, $offset, $len);
                my $actual_rows = POSIX::ceil(length($slice) / $bpr);
                my $expected = $actual_rows * $bpr;
                $slice .= "\0" x ($expected - length($slice)) if length($slice) < $expected;
                my $pb = eval { Gtk3::Gdk::Pixbuf->new_from_data($slice, 'rgb', FALSE, 8, $vw, $actual_rows, $bpr) };
                if ($pb) {
                    $pb->{_slice} = \$slice;
                    if (Gtk3::Gdk->can('cairo_set_source_pixbuf')) {
                        Gtk3::Gdk::cairo_set_source_pixbuf($cr, $pb, 0, $start_row);
                    } else { _cairo_set_source_pixbuf($cr, $pb, 0, $start_row); }
                    $cr->paint;
                }
            }
        }

        $popup_state->{overlay_map_areas} = [];
        for my $seg (@{$popup_state->{image_map_info}}) {
            my $start_pixel = POSIX::floor($seg->{offset_in_image} / 3);
            my $end_pixel   = POSIX::floor(($seg->{offset_in_image} + $seg->{length_in_image}) / 3);
            my $s_y = POSIX::floor($start_pixel / $vw); my $s_x = $start_pixel % $vw;
            my $e_y = POSIX::floor($end_pixel   / $vw); my $e_x = $end_pixel   % $vw;
            my @rects;
            if ($s_y == $e_y) { push @rects, [$s_x, $s_y, $e_x - $s_x + 1, 1]; }
            else {
                push @rects, [$s_x, $s_y, $vw - $s_x, 1];
                if ($e_y > $s_y + 1) { push @rects, [0, $s_y + 1, $vw, $e_y - ($s_y + 1)]; }
                push @rects, [0, $e_y, $e_x + 1, 1];
            }
            push @{$popup_state->{overlay_map_areas}}, { rects => \@rects, data => $seg };
        }

        if (defined $popup_state->{diff_points} &&
            $popup_state->{diff_toggle_button}  &&
            $popup_state->{diff_toggle_button}->get_active) {
            $cr->set_source_rgba(1, 0, 1, 0.6);
            for my $offset (@{$popup_state->{diff_points}}) {
                my $pixel_index = POSIX::floor($offset / 3);
                my $x = $pixel_index % $vw; my $y = POSIX::floor($pixel_index / $vw);
                next unless $y >= $start_row && $y <= $end_row;
                $cr->rectangle($x - 1, $y - 1, 3, 3); $cr->fill;
            }
        }
        if (@{$popup_state->{selection_highlight_rects}}) {
            $cr->set_source_rgba(0, 1, 1, 0.3);
            for my $rect (@{$popup_state->{selection_highlight_rects}}) { $cr->rectangle(@$rect); $cr->fill; }
        }
        if (defined $popup_state->{selection_start_offset}) {
            my $p = POSIX::floor($popup_state->{selection_start_offset} / 3);
            _draw_x_marker($cr, $p % $vw, POSIX::floor($p / $vw));
        }
        if (defined $popup_state->{selection_end_offset}) {
            my $p = POSIX::floor($popup_state->{selection_end_offset} / 3);
            _draw_x_marker($cr, $p % $vw, POSIX::floor($p / $vw));
        }
        if (defined $popup_state->{star_info}) {
            _draw_star($cr, $popup_state->{star_info}->{x}, $popup_state->{star_info}->{y});
        }
        return FALSE;
    });

    $da->signal_connect(button_press_event  => \&on_image_button_press, $popup_state);
    $da->signal_connect(motion_notify_event => \&on_image_motion,       $popup_state);
    $da->set_has_tooltip(TRUE);
    $da->signal_connect(query_tooltip => \&on_query_image_tooltip, $popup_state);

    my $update_image = sub {
        my $w = $width_adj->get_value;
        $w = int($w / 4) * 4; $w = 4 if $w < 4;
        my $pixels = int(length(${$popup_state->{raw_data_ref}}) / 3);
        my $h = POSIX::ceil($pixels / $w);
        $popup_state->{virtual_width}  = $w;
        $popup_state->{virtual_height} = $h;
        $da->set_size_request($w, $h);
        $info_label->set_markup(sprintf("<tt>%s | %s read | %dx%d virtual px | method: %s</tt>",
            $title_info->{title}, format_bytes(length(${$popup_state->{raw_data_ref}})),
            $w, $h, $state->{read_method}));
        $da->queue_draw();
        if (my $thumb = $popup_state->{thumbnail_window_ref}) { $thumb->queue_draw(); }
    };

    my $init_pixels = int(length($$raw_ref) / 3);
    my $init_width  = int(sqrt($init_pixels));
    $init_width = 1024 if $init_width > 1024;
    $init_width = int($init_width / 4) * 4; $init_width = 4 if $init_width < 4;
    if ($init_width > $max_entry->get_text) { $max_entry->set_text($init_width + 512); $apply_min_max->(); }
    $width_adj->set_value($init_width);
    $update_image->();

    $width_adj->signal_connect(value_changed => sub {
        if ($width_adj->get_value > $max_entry->get_text) {
            $max_entry->set_text(int($width_adj->get_value) + 512); $apply_min_max->();
        }
        $update_image->();
    });
    $btn_autowidth->signal_connect(clicked => sub {
        my $w = int(sqrt(int(length(${$popup_state->{raw_data_ref}}) / 3)));
        $w = int($w / 4) * 4; $w = 4 if $w < 4;
        if ($w > $max_entry->get_text) { $max_entry->set_text($w + 512); $apply_min_max->(); }
        $width_adj->set_value($w);
    });

    $btn_save_png->signal_connect(clicked => sub {
        my ($data_ref_for_action, $filename_offsets, $range_map, $path_info) = get_data_for_action($popup_state);
        return unless (defined $data_ref_for_action and length ${$data_ref_for_action} > 0);
        my $pixbuf_to_save = create_pixbuf_from_data($data_ref_for_action);
        return unless $pixbuf_to_save;
        my $chooser = Gtk3::FileChooserDialog->new("Save Memory Image", $popup, 'save', 'Cancel' => 'cancel', 'Save' => 'accept');
        $chooser->set_current_name(_generate_filename($popup_state, $filename_offsets, $path_info) . ".png");
        if ($chooser->run eq 'accept') {
            my $fn = $chooser->get_filename; $fn .= ".png" unless $fn =~ /\.png$/i;
            eval { $pixbuf_to_save->savev($fn, 'png', [], []); };
        }
        $chooser->destroy;
    });
    $btn_save_raw->signal_connect(clicked => sub {
        my ($data_ref_for_action, $filename_offsets, $range_map, $path_info) = get_data_for_action($popup_state);
        return unless (defined $data_ref_for_action and length ${$data_ref_for_action} > 0);
        my $chooser = Gtk3::FileChooserDialog->new("Save Raw Dump", $popup, 'save', 'Cancel' => 'cancel', 'Save' => 'accept');
        $chooser->set_current_name(_generate_filename($popup_state, $filename_offsets, $path_info) . ".raw");
        if ($chooser->run eq 'accept') {
            my $fn = $chooser->get_filename;
            if (open(my $fh, '>', $fn)) { binmode $fh; print $fh ${$data_ref_for_action}; close $fh; }
        }
        $chooser->destroy;
    });
    $btn_load_raw->signal_connect(clicked => sub {
        my $chooser = Gtk3::FileChooserDialog->new("Load Raw Dump", $popup, 'open', 'Cancel' => 'cancel', 'Open' => 'accept');
        if ($chooser->run eq 'accept') {
            my $fn = $chooser->get_filename;
            if (open(my $fh, '<', $fn)) {
                binmode $fh; my $d; read $fh, $d, -s $fh; close $fh;
                $popup_state->{loaded_raw_data_ref} = \$d;
                $popup_state->{status_is_green} = TRUE;
                $status_box->queue_draw; $diff_button->show; $diff_toggle->show;
            }
        }
        $chooser->destroy;
    });

    $diff_button->signal_connect(clicked => sub { perform_ram_diff($popup_state); $diff_toggle->set_active(TRUE); });
    $strings_button->signal_connect(clicked => \&show_strings_view, $popup_state);

    my $aplay_path = _find_executable('aplay');
    unless ($aplay_path) { $sound_button->set_sensitive(FALSE); $save_wav_button->set_sensitive(FALSE); }
    $sound_button->signal_connect(toggled => \&on_sound_toggle, $popup_state);

    $save_wav_button->signal_connect(clicked => sub {
        my ($data_ref_for_action, $filename_offsets, $range_map, $path_info) = get_data_for_action($popup_state);
        return unless (defined $data_ref_for_action and length ${$data_ref_for_action} > 0);
        my $chooser = Gtk3::FileChooserDialog->new("Save as WAV", $popup, 'save', 'Cancel' => 'cancel', 'Save' => 'accept');
        $chooser->set_current_name(_generate_filename($popup_state, $filename_offsets, $path_info) . ".wav");
        if ($chooser->run eq 'accept') {
            my $fn = $chooser->get_filename; $fn .= ".wav" unless $fn =~ /\.wav$/i;
            if (open(my $fh, '>', $fn)) {
                binmode $fh;
                print $fh _create_wav_header(length ${$data_ref_for_action});
                print $fh ${$data_ref_for_action};
                close $fh;
            }
        }
        $chooser->destroy;
    });

    $diff_toggle->signal_connect(toggled     => sub { $popup_state->{drawing_area}->queue_draw(); });
    $selection_toggle->signal_connect(toggled => sub { _update_selection_visuals($popup_state); });
    $start_entry->signal_connect(activate => \&on_offset_entry_activate, $popup_state);
    $end_entry->signal_connect(activate   => \&on_offset_entry_activate, $popup_state);
    $add_more_button->signal_connect(clicked => sub { _add_extra_selection_ui($extra_selections_hbox, $popup_state); });

    $infobar_toggle->signal_connect(toggled => sub {
        my $btn = shift;
        if ($btn->get_active) { $infobar_hbox->show_all(); } else { $infobar_hbox->hide(); }
    });
    $thumbnail_check->signal_connect(toggled => sub {
        my $btn = shift;
        if ($btn->get_active) { show_thumbnail_view($popup, $popup_state); }
        else {
            if (my $thumb_da = $popup_state->{thumbnail_window_ref}) {
                if (my $win = $thumb_da->get_toplevel) { $win->destroy; }
            }
        }
    });

    $decode_button->signal_connect(clicked   => sub { on_bitmap_decode_click($popup, $popup_state); });
    $digraph_button->signal_connect(clicked  => sub { show_digraph_view($popup, $popup_state); });
    $photorec_button->signal_connect(clicked => sub { on_photorec_click($popup, $popup_state); });

    $update_toggle->signal_connect(toggled => sub {
        my $button = shift;
        if ($button->get_active) {
            my $sec = $update_entry->get_text;
            if ($sec =~ /^\d*\.?\d+$/ && $sec > 0) {
                $button->set_label("Stop Updating");
                my $baseline = ${$popup_state->{raw_data_ref}};
                $popup_state->{update_baseline_ref} = \$baseline;
                $popup_state->{loaded_raw_data_ref} = undef;
                $popup_state->{diff_points}         = undef;
                $diff_button->hide; $diff_toggle->hide;
                $popup_state->{status_is_green} = FALSE;
                $status_box->queue_draw;
                $popup_state->{drawing_area}->queue_draw();
                $popup_state->{timer_id} = Glib::Timeout->add($sec * 1000, sub {
                    my $start_time = time(); my $new_raw;
                    if ($state->{read_method} =~ m{^/proc/(\d+)/mem}) {
                        my $res = read_drm_memory_for_pid($1);
                        if ($res) { $new_raw = $res->[0]; $popup_state->{image_map_info} = $res->[1]; }
                    } else {
                        my $start_idx = $popup_state->{image_map_info}[0]{idx};
                        my $offset    = $state->{regions}[$start_idx]{start} * 4096;
                        my $size      = length(${$popup_state->{raw_data_ref}});
                        $new_raw      = read_vram($offset, $size);
                    }
                    my $elapsed = time() - $start_time;
                    if ($new_raw) {
                        debug(sprintf("VRAM Image Popup: Auto-update read %s in %.3fs", format_bytes(length($$new_raw)), $elapsed));
                        if (defined $popup_state->{sound_player_pid}) { kill 'TERM', -($popup_state->{sound_player_pid}); }
                        $popup_state->{raw_data_ref} = $new_raw;
                        $update_image->();
                        if ($popup_state->{diff_toggle_button} && $popup_state->{diff_toggle_button}->get_active) {
                            perform_ram_diff($popup_state);
                        }
                        return TRUE;
                    }
                    $button->set_label("Read Failed"); $button->set_sensitive(FALSE);
                    return FALSE;
                });
            } else { $button->set_active(FALSE); }
        } else {
            if (defined $popup_state->{timer_id}) { Glib::Source->remove($popup_state->{timer_id}); $popup_state->{timer_id} = undef; }
            $button->set_label("Start Updating");
            if (defined $popup_state->{update_baseline_ref}) {
                $popup_state->{loaded_raw_data_ref} = $popup_state->{update_baseline_ref};
                $popup_state->{update_baseline_ref} = undef;
                $popup_state->{status_is_green} = TRUE;
                $status_box->queue_draw; $diff_button->show; $diff_toggle->show;
            }
        }
    });

    $popup->signal_connect(destroy => sub {
        if (defined $popup_state->{timer_id})      { Glib::Source->remove($popup_state->{timer_id});      $popup_state->{timer_id}      = undef; }
        if (defined $popup_state->{star_timer_id}) { Glib::Source->remove($popup_state->{star_timer_id}); $popup_state->{star_timer_id} = undef; }
        if (defined $popup_state->{sound_player_pid}) {
            kill 'TERM', -($popup_state->{sound_player_pid});
            if (defined $popup_state->{sound_child_watch_id}) { Glib::Source->remove($popup_state->{sound_child_watch_id}); }
            unlink $popup_state->{sound_temp_file} if defined $popup_state->{sound_temp_file};
        }
        $popup_state->{sound_player_pid} = $popup_state->{sound_child_watch_id} = $popup_state->{sound_temp_file} = undef;
        if (my $thumb_da = $popup_state->{thumbnail_window_ref}) {
            my $win = $thumb_da->get_toplevel; $win->destroy if $win;
        }
        $popup_state->{thumbnail_window_ref} = $popup_state->{thumbnail_check} = undef;
        $state->{range_start_offset} = $state->{range_end_offset} = $state->{range_anchor_offset} = undef;
        $state->{drawing_area}->queue_draw    if defined $state->{drawing_area};
        $state->{hilbert_window_ref}->queue_draw if defined $state->{hilbert_window_ref};
        $popup_state->{raw_data_ref} = $popup_state->{loaded_raw_data_ref} = $popup_state->{update_baseline_ref} = $popup_state->{diff_points} = undef;
        $popup_state->{image_map_info} = $popup_state->{overlay_map_areas} = $popup_state->{selection_highlight_rects} = $popup_state->{extra_selection_widgets} = [];
        $popup_state->{drawing_area} = $popup_state->{scrolled_window} = undef;
        $popup_state->{diff_toggle_button} = $popup_state->{overlay_toggle_button} = $popup_state->{update_toggle_button} = undef;
        $popup_state->{selection_toggle_button} = $popup_state->{start_offset_entry} = $popup_state->{end_offset_entry} = undef;
        $popup_state->{status_indicator_da} = undef; $popup_state->{infobar_labels} = {}; $popup_state->{star_info} = undef;
    });

    $diff_button->hide(); $diff_toggle->hide(); $infobar_hbox->hide();
    $popup->show_all;
    $diff_button->hide(); $diff_toggle->hide(); $infobar_hbox->hide();
}

# ---------------------------------------------------------------------------
# Interaction & Sub-Views for VRAM Image Popup
# ---------------------------------------------------------------------------
sub on_image_motion {
    my ($widget, $event, $popup_state) = @_;
    my $x = $event->x; my $y = $event->y;
    my $labels = $popup_state->{infobar_labels}; my $found = FALSE;
    for my $area (@{$popup_state->{overlay_map_areas}}) {
        for my $rect (@{$area->{rects}}) {
            my ($rx, $ry, $rw, $rh) = @$rect;
            if ($x >= $rx && $x < ($rx + $rw) && $y >= $ry && $y < ($ry + $rh)) {
                my $seg = $area->{data};
                $labels->{path}->set_text($seg->{path}  || "");
                $labels->{size}->set_text(format_bytes($seg->{size}));
                $labels->{perms}->set_text($seg->{perms});
                $labels->{range}->set_text(sprintf("0x%x - 0x%x", $seg->{start}, $seg->{end}));
                $found = TRUE; last;
            }
        }
        last if $found;
    }
    if (!$found) { $labels->{$_}->set_text("") for qw(path size perms range); }
    return TRUE;
}

sub on_image_button_press {
    my ($widget, $event, $popup_state) = @_;
    my $x = $event->x; my $y = $event->y;
    my $sw   = $popup_state->{scrolled_window};
    my $sx   = defined $sw ? $sw->get_hadjustment->get_value : 0;
    my $sy_s = defined $sw ? $sw->get_vadjustment->get_value : 0;
    debug(sprintf("CLICK btn=%d event_xy=(%.0f,%.0f) scroll=(%.0f,%.0f) vw=%d",
        $event->button, $event->x, $event->y, $sx, $sy_s, $popup_state->{virtual_width}));
    if ($event->button == 1) {
        for my $area (@{$popup_state->{overlay_map_areas}}) {
            for my $rect (@{$area->{rects}}) {
                my ($rx, $ry, $rw, $rh) = @$rect;
                if ($x >= $rx && $x < ($rx + $rw) && $y >= $ry && $y < ($ry + $rh)) {
                    my $seg = $area->{data};
                    $popup_state->{selection_start_offset} = $seg->{offset_in_image};
                    $popup_state->{selection_end_offset}   = $seg->{offset_in_image} + $seg->{length_in_image} - 1;
                    _update_selection_visuals($popup_state);
                    return TRUE;
                }
            }
        }
    } elsif ($event->button == 3) {
        my $w = $popup_state->{virtual_width};
        my $byte_offset = POSIX::floor($y * $w + $x) * 3;
        if (!defined $popup_state->{selection_start_offset}) {
            $popup_state->{selection_start_offset} = $byte_offset; $popup_state->{selection_end_offset} = undef;
        } elsif (!defined $popup_state->{selection_end_offset}) {
            $popup_state->{selection_end_offset} = $byte_offset;
        } else {
            $popup_state->{selection_start_offset} = $byte_offset; $popup_state->{selection_end_offset} = undef;
        }
        _update_selection_visuals($popup_state); return TRUE;
    }
    return FALSE;
}

sub on_query_image_tooltip {
    my ($widget, $event_x, $event_y, $keyboard_mode, $tooltip, $popup_state) = @_;
    my $x = $event_x; my $y = $event_y;
    for my $area (@{$popup_state->{overlay_map_areas}}) {
        for my $rect (@{$area->{rects}}) {
            my ($rx, $ry, $rw, $rh) = @$rect;
            if ($x >= $rx && $x <= ($rx + $rw) && $y >= $ry && $y <= ($ry + $rh)) {
                my $seg = $area->{data};
                $tooltip->set_markup(sprintf("<b>Path:</b> %s\n<b>Size:</b> %s\n<b>Range:</b> 0x%x - 0x%x",
                    $seg->{path}, format_bytes($seg->{size}), $seg->{start}, $seg->{end}));
                return TRUE;
            }
        }
    }
    return FALSE;
}

sub get_data_for_action {
    my ($popup_state) = @_;
    my $concatenated_data = ""; my @filename_parts; my @range_map; my $first_offset;
    if ($popup_state->{selection_toggle_button}->get_active &&
        defined $popup_state->{selection_start_offset} &&
        defined $popup_state->{selection_end_offset}) {
        my $start = $popup_state->{selection_start_offset};
        my $end   = $popup_state->{selection_end_offset};
        ($start, $end) = ($end, $start) if $start > $end;
        my $length = $end - $start + 1;
        if ($length > 0 && $start + $length <= length(${$popup_state->{raw_data_ref}})) {
            $first_offset = $start unless defined $first_offset;
            push @range_map, [$start, $length, length($concatenated_data)];
            $concatenated_data .= substr(${$popup_state->{raw_data_ref}}, $start, $length);
            push @filename_parts, sprintf("0x%X-0x%X", $start, $end);
        }
    }
    for my $wset (@{$popup_state->{extra_selection_widgets}}) {
        if ($wset->{toggle}->get_active) {
            my $s = ($wset->{start_entry}->get_text =~ /^0x([0-9a-f]+)$/i) ? hex($1) : undef;
            my $e = ($wset->{end_entry}->get_text   =~ /^0x([0-9a-f]+)$/i) ? hex($1) : undef;
            if (defined $s && defined $e) {
                ($s, $e) = ($e, $s) if $s > $e;
                my $len = $e - $s + 1;
                if ($len > 0 && $s + $len <= length(${$popup_state->{raw_data_ref}})) {
                    $first_offset = $s unless defined $first_offset;
                    push @range_map, [$s, $len, length($concatenated_data)];
                    $concatenated_data .= substr(${$popup_state->{raw_data_ref}}, $s, $len);
                    push @filename_parts, sprintf("0x%X-0x%X", $s, $e);
                }
            }
        }
    }
    my $path_info = (defined $first_offset) ? _get_segment_info_for_offset($popup_state, $first_offset) : "";
    if (length $concatenated_data > 0) {
        return (\$concatenated_data, join('_', @filename_parts), \@range_map, $path_info);
    } else { return ($popup_state->{raw_data_ref}, undef, undef, undef); }
}

sub _generate_filename {
    my ($popup_state, $offsets_str, $path_info) = @_;
    my $pname = $popup_state->{pname};
    $pname =~ s/[^A-Za-z0-9\._-]+/_/g;
    my @parts = ($pname, 'pid', $popup_state->{pid}, 'memory');
    push @parts, $offsets_str if defined $offsets_str and $offsets_str ne '';
    push @parts, $path_info   if defined $path_info   and $path_info   ne '';
    return join('_', @parts);
}

sub _get_segment_info_for_offset {
    my ($popup_state, $target_offset) = @_;
    return "" unless defined $target_offset;
    for my $seg (@{$popup_state->{image_map_info}}) {
        my $start = $seg->{offset_in_image}; my $end = $start + $seg->{length_in_image};
        if ($target_offset >= $start && $target_offset < $end) {
            my $path = $seg->{path} || "anonymous";
            $path =~ s/\[heap\]/HEAP/i;
            $path =~ s/^.+?([^\/]+)$/$1/ if $path =~ m|/|;
            $path =~ s/[^A-Za-z0-9\._-]+/_/g;
            return $path;
        }
    }
    return "unknown-segment";
}

sub perform_ram_diff {
    my ($popup_state) = @_;
    return unless (defined $popup_state->{raw_data_ref} && defined $popup_state->{loaded_raw_data_ref});
    my $live_data = ${$popup_state->{raw_data_ref}}; my $loaded_data = ${$popup_state->{loaded_raw_data_ref}};
    my @diffs;
    my $len = length($live_data) < length($loaded_data) ? length($live_data) : length($loaded_data);
    for my $i (0 .. $len - 1) { push @diffs, $i if substr($live_data, $i, 1) ne substr($loaded_data, $i, 1); }
    $popup_state->{diff_points} = \@diffs;
    $popup_state->{drawing_area}->queue_draw();
}

sub _draw_x_marker {
    my ($cr, $cx, $cy) = @_;
    my $size = 8;
    $cr->save; $cr->translate($cx, $cy);
    $cr->move_to(-$size, -$size); $cr->line_to($size, $size);
    $cr->move_to(-$size, $size);  $cr->line_to($size, -$size);
    $cr->set_line_width(5); $cr->set_source_rgb(1, 1, 1); $cr->stroke_preserve;
    $cr->set_line_width(2.5); $cr->set_source_rgb(0, 0, 0); $cr->stroke_preserve;
    $cr->set_line_width(1.5); $cr->set_source_rgba(1, 1, 0, 0.8); $cr->stroke;
    $cr->restore;
}

sub _draw_star {
    my ($cr, $cx, $cy) = @_;
    my ($radius1, $radius2, $points) = (12, 6, 5);
    $cr->save; $cr->translate($cx, $cy);
    $cr->move_to($radius1, 0);
    for my $i (1 .. $points * 2) {
        my $angle = $i * 3.14159265 / $points;
        my $r = ($i % 2) == 0 ? $radius1 : $radius2;
        $cr->line_to($r * cos($angle), $r * sin($angle));
    }
    $cr->close_path;
    $cr->set_line_width(5); $cr->set_source_rgb(1, 1, 1); $cr->stroke_preserve;
    $cr->set_line_width(2.5); $cr->set_source_rgb(0, 0, 0); $cr->stroke_preserve;
    $cr->set_source_rgb(1.0, 0.84, 0); $cr->fill;
    $cr->restore;
}

sub on_offset_entry_activate {
    my ($entry, $popup_state) = @_;
    $popup_state->{selection_start_offset} =
        ($popup_state->{start_offset_entry}->get_text =~ /^0x([0-9a-f]+)$/i) ? hex($1) : undef;
    $popup_state->{selection_end_offset} =
        ($popup_state->{end_offset_entry}->get_text =~ /^0x([0-9a-f]+)$/i) ? hex($1) : undef;
    _update_selection_visuals($popup_state);
}

sub _update_selection_visuals {
    my ($popup_state) = @_;
    my ($s, $e) = ($popup_state->{selection_start_offset}, $popup_state->{selection_end_offset});
    $popup_state->{start_offset_entry}->set_text(defined $s ? sprintf("0x%X", $s) : '');
    $popup_state->{end_offset_entry}->set_text(  defined $e ? sprintf("0x%X", $e) : '');
    my @all_rects; my $iw = $popup_state->{virtual_width};
    return unless $iw > 0;
    my $calc_rects = sub {
        my ($s, $e) = @_; return () unless (defined $s && defined $e);
        ($s, $e) = ($e, $s) if $s > $e;
        my $sp = POSIX::floor($s/3); my $ep = POSIX::floor($e/3);
        my $sy = POSIX::floor($sp/$iw); my $sx = $sp % $iw;
        my $ey = POSIX::floor($ep/$iw); my $ex = $ep % $iw;
        my @rects;
        if ($sy == $ey) { push @rects, [$sx, $sy, $ex - $sx + 1, 1]; }
        else {
            push @rects, [$sx, $sy, $iw - $sx, 1];
            if ($ey > $sy + 1) { push @rects, [0, $sy + 1, $iw, $ey - ($sy + 1)]; }
            push @rects, [0, $ey, $ex + 1, 1];
        }
        return @rects;
    };
    push @all_rects, $calc_rects->($s, $e) if $popup_state->{selection_toggle_button}->get_active;
    for my $wset (@{$popup_state->{extra_selection_widgets}}) {
        if ($wset->{toggle}->get_active) {
            my $ws = ($wset->{start_entry}->get_text =~ /^0x([0-9a-f]+)$/i) ? hex($1) : undef;
            my $we = ($wset->{end_entry}->get_text   =~ /^0x([0-9a-f]+)$/i) ? hex($1) : undef;
            push @all_rects, $calc_rects->($ws, $we);
        }
    }
    $popup_state->{selection_highlight_rects} = \@all_rects;
    $popup_state->{drawing_area}->queue_draw();
}

sub _add_extra_selection_ui {
    my ($container, $popup_state) = @_;
    my $hbox    = Gtk3::Box->new('horizontal', 5);
    my $s_entry = Gtk3::Entry->new(); $s_entry->set_width_chars(10);
    my $e_entry = Gtk3::Entry->new(); $e_entry->set_width_chars(10);
    my $toggle  = Gtk3::CheckButton->new(); $toggle->set_active(TRUE);
    $hbox->pack_start(Gtk3::Label->new("Start:"), FALSE, FALSE, 5);
    $hbox->pack_start($s_entry, FALSE, FALSE, 0);
    $hbox->pack_start(Gtk3::Label->new("Stop:"),  FALSE, FALSE, 5);
    $hbox->pack_start($e_entry, FALSE, FALSE, 0);
    $hbox->pack_start($toggle,  FALSE, FALSE, 5);
    push @{$popup_state->{extra_selection_widgets}}, { start_entry => $s_entry, end_entry => $e_entry, toggle => $toggle };
    my $cb = sub { _update_selection_visuals($popup_state); };
    $s_entry->signal_connect(activate => $cb);
    $e_entry->signal_connect(activate => $cb);
    $toggle->signal_connect(toggled   => $cb);
    $container->pack_start($hbox, FALSE, FALSE, 0);
    $container->show_all;
}

sub show_thumbnail_view {
    my ($parent, $popup_state) = @_;
    if (defined $popup_state->{thumbnail_window_ref}) {
        my $win = $popup_state->{thumbnail_window_ref}->get_toplevel; $win->present if $win; return;
    }
    my $vw = $popup_state->{virtual_width}; my $vh = $popup_state->{virtual_height};
    return unless $vw > 0 && $vh > 0;
    my $tw = 300; my $scale_x = $tw / $vw; my $th = int($vh * $scale_x); my $scale_y = $scale_x;
    if ($th > 800) { $th = 800; $scale_y = $th / $vh; }
    my $thumb_data = "\0" x ($tw * $th * 3);
    my $raw = ${$popup_state->{raw_data_ref}}; my $raw_len = length($raw);
    for my $y (0 .. $th - 1) {
        my $src_y = int($y / $scale_y);
        for my $x (0 .. $tw - 1) {
            my $src_x = int($x / $scale_x);
            my $offset = ($src_y * $vw + $src_x) * 3;
            if ($offset + 2 < $raw_len) { substr($thumb_data, ($y * $tw + $x) * 3, 3) = substr($raw, $offset, 3); }
        }
    }
    my $thumb_pixbuf = Gtk3::Gdk::Pixbuf->new_from_data($thumb_data, 'rgb', FALSE, 8, $tw, $th, $tw * 3);
    my $sw   = $popup_state->{scrolled_window};
    my $hadj = $sw->get_hadjustment; my $vadj = $sw->get_vadjustment;
    my $thumb_state = { pixbuf => $thumb_pixbuf, scale_x => $scale_x, scale_y => $scale_y, hadj => $hadj, vadj => $vadj };
    my $win = Gtk3::Window->new('toplevel');
    $win->set_title("Thumbnail View"); $win->set_transient_for($parent); $win->set_position('center-on-parent');
    my $overlay = Gtk3::Overlay->new;
    my $img = Gtk3::Image->new_from_pixbuf($thumb_pixbuf);
    $img->set_halign('start'); $img->set_valign('start'); $img->set_size_request($tw, $th);
    my $da = Gtk3::DrawingArea->new;
    $da->set_size_request($tw, $th); $da->set_halign('start'); $da->set_valign('start');
    $da->set_app_paintable(TRUE); $da->set_events(['button-press-mask', 'pointer-motion-mask']);
    $da->signal_connect(draw => \&on_draw_thumbnail, $thumb_state);
    my $nav_cb = sub {
        my ($widget, $event) = @_;
        my $fx = $event->x / $thumb_state->{scale_x}; my $fy = $event->y / $thumb_state->{scale_y};
        my ($h_page, $v_page) = ($thumb_state->{hadj}->get('page-size'), $thumb_state->{vadj}->get('page-size'));
        my $nx = $fx - ($h_page / 2); my $ny = $fy - ($v_page / 2);
        $nx = 0 if $nx < 0; $ny = 0 if $ny < 0;
        my $mx = $thumb_state->{hadj}->get('upper') - $h_page; my $my = $thumb_state->{vadj}->get('upper') - $v_page;
        $nx = $mx if $nx > $mx; $ny = $my if $ny > $my;
        $thumb_state->{hadj}->set_value($nx); $thumb_state->{vadj}->set_value($ny); return TRUE;
    };
    $da->signal_connect(button_press_event => $nav_cb);
    $da->signal_connect(motion_notify_event => sub {
        my ($widget, $event) = @_; return FALSE unless $event->state >= 'button1-mask'; return $nav_cb->($widget, $event);
    });
    $overlay->add($img); $overlay->add_overlay($da); $win->add($overlay);
    $popup_state->{thumbnail_window_ref} = $da;
    my $thumb_redraw = sub { $da->queue_draw if defined $popup_state->{thumbnail_window_ref}; };
    my $hadj_id = $hadj->signal_connect(value_changed => $thumb_redraw);
    my $vadj_id = $vadj->signal_connect(value_changed => $thumb_redraw);
    $win->signal_connect(destroy => sub {
        $hadj->signal_handler_disconnect($hadj_id); $vadj->signal_handler_disconnect($vadj_id);
        $popup_state->{thumbnail_window_ref} = undef;
        $popup_state->{thumbnail_check}->set_active(FALSE) if defined $popup_state->{thumbnail_check};
        $thumb_state->{pixbuf} = $thumb_state->{hadj} = $thumb_state->{vadj} = undef; $thumb_data = '';
    });
    $win->show_all;
}

sub on_draw_thumbnail {
    my ($widget, $cr, $ts) = @_;
    my ($h_val, $v_val)   = ($ts->{hadj}->get_value, $ts->{vadj}->get_value);
    my ($h_page, $v_page) = ($ts->{hadj}->get('page-size'), $ts->{vadj}->get('page-size'));
    my $rx = $h_val * $ts->{scale_x}; my $ry = $v_val * $ts->{scale_y};
    my $rw = $h_page * $ts->{scale_x}; my $rh = $v_page * $ts->{scale_y};
    $cr->set_source_rgba(1, 0, 1, 0.4); $cr->rectangle($rx, $ry, $rw, $rh); $cr->fill;
    $cr->set_source_rgba(1, 0, 1, 0.9); $cr->set_line_width(1.5); $cr->rectangle($rx, $ry, $rw, $rh); $cr->stroke;
    return TRUE;
}

sub _create_wav_header {
    my ($data_length) = @_;
    my $header;
    $header .= pack('A4', 'RIFF'); $header .= pack('V', 36 + $data_length);
    $header .= pack('A4', 'WAVE'); $header .= pack('A4', 'fmt ');
    $header .= pack('V', 16); $header .= pack('v', 1); $header .= pack('v', 1);
    $header .= pack('V', 8000); $header .= pack('V', 8000);
    $header .= pack('v', 1); $header .= pack('v', 8);
    $header .= pack('A4', 'data'); $header .= pack('V', $data_length);
    return $header;
}

sub on_sound_finished {
    my ($pid, $status, $data) = @_;
    my ($button, $popup_state) = @$data;
    return unless (defined $popup_state->{sound_player_pid} && $popup_state->{sound_player_pid} == $pid);
    $popup_state->{_is_changing_sound_button} = TRUE; $button->set_active(FALSE); $popup_state->{_is_changing_sound_button} = FALSE;
    Glib::Source->remove($popup_state->{sound_child_watch_id}) if defined $popup_state->{sound_child_watch_id};
    unlink $popup_state->{sound_temp_file} if defined $popup_state->{sound_temp_file};
    $popup_state->{sound_player_pid} = $popup_state->{sound_child_watch_id} = $popup_state->{sound_temp_file} = undef;
}

sub on_sound_toggle {
    my ($button, $popup_state, $data_getter) = @_;
    $data_getter ||= \&get_data_for_action;
    return if $popup_state->{_is_changing_sound_button};
    if ($button->get_active) {
        return if defined $popup_state->{sound_player_pid};
        my ($data_ref) = $data_getter->($popup_state);
        unless (length ${$data_ref} > 0) {
            $popup_state->{_is_changing_sound_button} = TRUE; $button->set_active(FALSE); $popup_state->{_is_changing_sound_button} = FALSE; return;
        }
        my ($fh, $fn) = tempfile(UNLINK => 0);
        binmode $fh; print $fh ${$data_ref}; close $fh;
        $popup_state->{sound_temp_file} = $fn; chmod 0644, $fn;
        my $user = qx(loginctl list-sessions --no-legend | grep "seat0" | awk '{print \$3}'); chomp $user;
        my $uid = $user ? getpwnam($user) : undef;
        my $pid = fork(); die "Cannot fork: $!" unless defined $pid;
        if ($pid == 0) {
            setpgrp(0, 0);
            if ($user && defined $uid) { exec("sudo", "-u", $user, "env", "XDG_RUNTIME_DIR=/run/user/$uid", "aplay", "-q", "-f", "U8", "-r", "8000", "-c", "1", $fn); }
            else { exec('aplay', '-q', '-f', 'U8', '-r', '8000', '-c', '1', $fn); }
            die "Failed to exec aplay: $!";
        } else {
            $popup_state->{sound_player_pid} = $pid;
            $popup_state->{sound_child_watch_id} = Glib::Child->watch_add($pid, \&on_sound_finished, [$button, $popup_state]);
        }
    } else { kill 'TERM', -($popup_state->{sound_player_pid}) if defined $popup_state->{sound_player_pid}; }
}

sub show_strings_view {
    my ($button, $popup_state) = @_;
    my $parent_window = $button->get_toplevel;
    my ($data_ref, undef, $range_map_ref, undef) = get_data_for_action($popup_state);
    return unless defined $data_ref && ${$data_ref};
    my ($fh, $fn) = tempfile(UNLINK => 1); binmode $fh; print $fh ${$data_ref}; close $fh;
    my $out = `strings -n 8 "$fn"`;
    my $win = Gtk3::Window->new('toplevel'); $win->set_title("Strings"); $win->set_transient_for($parent_window);
    $win->set_default_size(700, 500); $win->set_position('center-on-parent');
    my $vbox = Gtk3::Box->new('vertical', 5); $win->add($vbox);
    my $search_hbox = Gtk3::Box->new('horizontal', 5);
    my $entry = Gtk3::Entry->new(); my $btn = Gtk3::Button->new_from_icon_name('edit-find', 'button');
    $search_hbox->pack_start(Gtk3::Label->new("Find:"), FALSE, FALSE, 0);
    $search_hbox->pack_start($entry, TRUE,  TRUE,  0);
    $search_hbox->pack_start($btn,   FALSE, FALSE, 0);
    $vbox->pack_start($search_hbox, FALSE, FALSE, 0);
    my $sw = Gtk3::ScrolledWindow->new(undef, undef); $sw->set_policy('automatic', 'automatic');
    $vbox->pack_start($sw, TRUE, TRUE, 0);
    my $tv = Gtk3::TextView->new(); $tv->set_editable(FALSE); $tv->set_cursor_visible(TRUE);
    my $buffer = $tv->get_buffer(); $buffer->set_text($out || "No strings found."); $sw->add($tv);
    my $search_state = { original_text => $out, last_search_term => undef, byte_offsets => [], current_match_index => -1 };
    my $cb_data = [$entry, $tv, $search_state, $popup_state, $data_ref, $range_map_ref];
    $btn->signal_connect(clicked  => \&search_and_highlight, $cb_data);
    $entry->signal_connect(activate => \&search_and_highlight, $cb_data);
    $win->signal_connect(destroy => sub {
        $out = ''; $search_state->{original_text} = undef; $search_state->{byte_offsets} = [];
        $search_state->{last_search_term} = undef; @$cb_data = ();
    });
    $win->show_all;
}

sub _translate_concatenated_offset {
    my ($rel, $range_map_ref) = @_;
    return $rel unless (defined $range_map_ref && @$range_map_ref);
    for my $r (reverse @$range_map_ref) {
        my ($abs_s, $len, $concat_s) = @$r;
        if ($rel >= $concat_s) { return $abs_s + ($rel - $concat_s); }
    }
    return $rel;
}

sub search_and_highlight {
    my ($widget, $data) = @_;
    my ($entry, $tv, $s_state, $p_state, $data_ref, $range_map_ref) = @$data;
    my $buffer = $tv->get_buffer(); my $term = $entry->get_text();
    unless (length $term) {
        $buffer->set_text($s_state->{original_text} || "No strings found.");
        $s_state->{last_search_term} = undef; $s_state->{byte_offsets} = []; $s_state->{current_match_index} = -1;
        return;
    }
    if (!defined $s_state->{last_search_term} || $term ne $s_state->{last_search_term}) {
        $s_state->{byte_offsets} = []; $s_state->{current_match_index} = -1; $s_state->{last_search_term} = $term;
        my $concat = ${$data_ref}; my $rel = -1; my $res = "";
        while (($rel = CORE::index($concat, $term, $rel + 1)) != -1) {
            my $abs = _translate_concatenated_offset($rel, $range_map_ref);
            push @{$s_state->{byte_offsets}}, $abs;
            my $c_start = $abs - 40; $c_start = 0 if $c_start < 0;
            my $ctx = substr(${$p_state->{raw_data_ref}}, $c_start, length($term) + 80);
            $ctx =~ s/[^\x20-\x7E]/./g;
            $res .= sprintf("0x%X: %s\n", $abs, $ctx);
        }
        $buffer->set_text($res || "String not found.");
    }
    return unless @{$s_state->{byte_offsets}};
    $s_state->{current_match_index} = ($s_state->{current_match_index} + 1) % @{$s_state->{byte_offsets}};
    my $idx = $s_state->{current_match_index};
    my ($si, $ei) = $buffer->get_bounds();
    my $tt = $buffer->get_tag_table();
    my $tag = $tt->lookup('hl');
    unless ($tag) { $tag = Gtk3::TextTag->new('hl'); $tag->set_property('background', 'yellow'); $tt->add($tag); }
    $buffer->remove_tag_by_name('hl', $si, $ei);
    my $lsi = $buffer->get_iter_at_line($idx); my $lei = $lsi->copy; $lei->forward_to_line_end();
    $buffer->apply_tag_by_name('hl', $lsi, $lei); $tv->scroll_to_iter($lsi, 0.0, TRUE, 0.0, 0.5);
    my $byte = $s_state->{byte_offsets}->[$idx];
    my $vw = $p_state->{virtual_width};
    if (defined $byte && $vw > 0) {
        my $px = POSIX::floor($byte / 3);
        $p_state->{star_info} = { x => $px % $vw, y => POSIX::floor($px / $vw) };
        if (defined $p_state->{star_timer_id}) { Glib::Source->remove($p_state->{star_timer_id}); }
        $p_state->{star_timer_id} = Glib::Timeout->add(15000, sub {
            $p_state->{star_info} = undef; $p_state->{drawing_area}->queue_draw(); return FALSE;
        });
        $p_state->{drawing_area}->queue_draw();
        if (my $sw = $p_state->{scrolled_window}) {
            if (my $vadj = $sw->get_vadjustment) {
                my $ny = $p_state->{star_info}->{y} - ($vadj->get('page-size') / 2); $ny = 0 if $ny < 0; $vadj->set_value($ny);
            }
            if (my $hadj = $sw->get_hadjustment) {
                my $nx = $p_state->{star_info}->{x} - ($hadj->get('page-size') / 2); $nx = 0 if $nx < 0; $hadj->set_value($nx);
            }
        }
    }
}

sub _generate_digraph_pixbuf {
    my ($digraph_state, $is_weighted) = @_;
    my $counts = $digraph_state->{counts}; my $max_count = $digraph_state->{max_count};
    my $pixels = "\0" x (256 * 256 * 3); my $log_max = ($max_count > 0) ? log(1 + $max_count) : 1;
    for my $y (0 .. 255) {
        for my $x (0 .. 255) {
            my $c = $counts->[$y][$x]; next if $c == 0;
            my $g = $is_weighted ? int((log(1 + $c) / $log_max) * 255) : 255;
            $g = 255 if $g > 255;
            substr($pixels, $y * 256 * 3 + $x * 3 + 1, 1) = pack('C', $g);
        }
    }
    return Gtk3::Gdk::Pixbuf->new_from_data($pixels, 'rgb', 0, 8, 256, 256, 256 * 3);
}

sub show_digraph_view {
    my ($parent, $popup_state) = @_;
    my ($data_ref, $offs_str, undef, $path_info) = get_data_for_action($popup_state);
    return unless defined $data_ref && length($$data_ref) > 1;
    my $info_text = _generate_filename($popup_state, $offs_str, $path_info);
    my @counts = map { [ (0) x 256 ] } (0..255);
    my $max = 0; my @bytes = unpack 'C*', $$data_ref;
    for my $i (0 .. $#bytes - 1) { my $x = $bytes[$i]; my $y = $bytes[$i+1]; $counts[$y][$x]++; $max = $counts[$y][$x] if $counts[$y][$x] > $max; }
    my $ds = { pixbuf => undef, counts => \@counts, max_count => $max };
    $ds->{pixbuf} = _generate_digraph_pixbuf($ds, FALSE);
    my $win = Gtk3::Window->new('toplevel'); $win->set_title(" Digraph View"); $win->set_transient_for($parent); $win->set_position('center-on-parent');
    my $vbox = Gtk3::Box->new('vertical', 5); $win->add($vbox);
    my $hbox = Gtk3::Box->new('horizontal', 5); my $w_tog = Gtk3::ToggleButton->new_with_label("Weighted");
    my $btn_png = Gtk3::Button->new_from_icon_name('document-save', 'button');
    $hbox->pack_start($w_tog, FALSE, FALSE, 0); $hbox->pack_start($btn_png, FALSE, FALSE, 5);
    $vbox->pack_start($hbox, FALSE, FALSE, 0);
    my $img = Gtk3::Image->new_from_pixbuf($ds->{pixbuf}); $vbox->pack_start($img, TRUE, TRUE, 5);
    $w_tog->signal_connect(toggled => sub { my $new_pb = _generate_digraph_pixbuf($ds, shift->get_active); $ds->{pixbuf} = $new_pb; $img->set_from_pixbuf($new_pb); });
    $btn_png->signal_connect(clicked => sub {
        my $chooser = Gtk3::FileChooserDialog->new("Save Digraph", $win, 'save', 'Cancel' => 'cancel', 'Save' => 'accept');
        $chooser->set_current_name($info_text . "_digraph.png");
        if ($chooser->run eq 'accept') { eval { $ds->{pixbuf}->savev($chooser->get_filename, 'png', [], []); }; }
        $chooser->destroy;
    });
    $win->signal_connect(destroy => sub { @bytes = (); $ds->{counts} = []; $ds->{pixbuf} = undef; $ds->{max_count} = 0; });
    $win->show_all;
}

sub on_bitmap_decode_click {
    my ($parent, $popup_state) = @_;
    my ($data_ref) = get_data_for_action($popup_state);
    return unless defined $data_ref && $$data_ref;
    show_bitmap_decode_result($parent, $popup_state, $data_ref, 256, 200, 800);
}

sub show_bitmap_decode_result {
    my ($parent, $main_state, $raw_ref, $init_w, $min_w, $max_w) = @_;
    my $win  = Gtk3::Window->new('toplevel'); $win->set_transient_for($parent); $win->set_position('center-on-parent');
    my $vbox = Gtk3::Box->new('vertical', 2); $win->add($vbox);
    my $raw = $$raw_ref; my $sz = length($raw);
    $min_w = 1 if $min_w < 1; $max_w = 8192 if $max_w > 8192;
    $init_w = $min_w if $init_w < $min_w; $init_w = $max_w if $init_w > $max_w;
    my $w_adj = Gtk3::Adjustment->new($init_w, $min_w, $max_w + 1, 1, 10, 0);
    my $tb1 = Gtk3::Box->new('horizontal', 5); $tb1->set_border_width(5);
    my $btn_save = Gtk3::Button->new_with_label("Save PNG"); my $btn_find = Gtk3::Button->new_with_label("Find Width");
    my $min_entry = Gtk3::Entry->new; $min_entry->set_width_chars(5); $min_entry->set_text($min_w);
    my $max_entry = Gtk3::Entry->new; $max_entry->set_width_chars(5); $max_entry->set_text($max_w);
    my $w_entry = Gtk3::Entry->new; $w_entry->set_width_chars(5); $w_entry->set_text($init_w);
    my $w_scale = Gtk3::Scale->new('horizontal', $w_adj); $w_scale->set_draw_value(FALSE);
    $tb1->pack_start($btn_save, FALSE, FALSE, 0); $tb1->pack_start(Gtk3::Label->new("Min:"), FALSE, FALSE, 2);
    $tb1->pack_start($min_entry, FALSE, FALSE, 0); $tb1->pack_start($w_scale, TRUE, TRUE, 5);
    $tb1->pack_start(Gtk3::Label->new("Max:"), FALSE, FALSE, 2); $tb1->pack_start($max_entry, FALSE, FALSE, 0);
    $tb1->pack_start(Gtk3::Label->new("W:"), FALSE, FALSE, 2); $tb1->pack_start($w_entry, FALSE, FALSE, 0);
    $tb1->pack_end($btn_find, FALSE, FALSE, 10); $vbox->pack_start($tb1, FALSE, FALSE, 0);
    my $apply_min_max = sub {
        my $new_min = $min_entry->get_text; my $new_max = $max_entry->get_text;
        $new_min = 1    unless $new_min =~ /^\d+$/ && $new_min >= 1;
        $new_max = 8192 unless $new_max =~ /^\d+$/ && $new_max >= 1;
        $new_min = 1 if $new_min < 1; ($new_min, $new_max) = ($new_max, $new_min) if $new_min > $new_max;
        $min_entry->set_text($new_min); $max_entry->set_text($new_max);
        $w_adj->configure($w_adj->get_value, $new_min, $new_max + 1, $w_adj->get_step_increment, $w_adj->get_page_increment, $w_adj->get_page_size);
    };
    $min_entry->signal_connect(activate => $apply_min_max); $max_entry->signal_connect(activate => $apply_min_max);
    $w_entry->signal_connect(activate => sub {
        my $val = $w_entry->get_text;
        if ($val =~ /^\d+$/ && $val >= 1) {
            if ($val > $max_entry->get_text) { $max_entry->set_text($val + 512); $apply_min_max->(); }
            if ($val < $min_entry->get_text) { $min_entry->set_text($val); $apply_min_max->(); }
            $w_adj->set_value($val);
        }
    });
    my $tb2 = Gtk3::Box->new('horizontal', 5); $tb2->set_border_width(5);
    my $off_adj = Gtk3::Adjustment->new(0, 0, $sz > 0 ? $sz - 1 : 0, 1, 256, 0);
    $tb2->pack_start(Gtk3::Label->new("Pixel Byte Offset: "), FALSE, FALSE, 0);
    $tb2->pack_start(Gtk3::SpinButton->new($off_adj, 0, 0), TRUE, TRUE, 5);
    my $fmt_store = Gtk3::ListStore->new('Glib::String', 'Glib::String', 'Glib::Int');
    my $fmt_combo = Gtk3::ComboBox->new_with_model($fmt_store);
    my $rend = Gtk3::CellRendererText->new; $fmt_combo->pack_start($rend, TRUE); $fmt_combo->add_attribute($rend, 'text', 0);
    for my $f (["24-bit RGB",'rgb24',3], ["32-bit RGBA",'rgba32',4], ["8-bit Grayscale",'gray8',1], ["16-bit Grayscale (LE)",'gray16le',2]) {
        $fmt_store->set($fmt_store->append, 0, $f->[0], 1, $f->[1], 2, $f->[2]);
    }
    $fmt_combo->set_active(0);
    $tb2->pack_start(Gtk3::Label->new("Format:"), FALSE, FALSE, 10); $tb2->pack_start($fmt_combo, FALSE, FALSE, 0);
    $vbox->pack_start($tb2, FALSE, FALSE, 0);
    my $decode_state = { vw => 0, vh => 0, bpp => 3, fmt => 'rgb24', bo => 0 };
    my $bsw = Gtk3::ScrolledWindow->new(undef, undef); $bsw->set_policy('automatic', 'automatic'); $bsw->set_property('overlay-scrolling', 0);
    my $da = Gtk3::DrawingArea->new(); $da->set_events(['scroll-mask']); $da->set_size_request(1, 1);
    $bsw->add($da); $vbox->pack_start($bsw, TRUE, TRUE, 0);
    $da->signal_connect(draw => sub {
        my ($widget, $cr) = @_; $cr->set_source_rgb(0.08, 0.08, 0.08); $cr->paint;
        my $vw = $decode_state->{vw}; my $vh = $decode_state->{vh}; my $bpp = $decode_state->{bpp};
        my $fmt = $decode_state->{fmt}; my $bo = $decode_state->{bo};
        return FALSE unless $vw > 0 && $vh > 0;
        my ($cx, $cy, $cx2, $cy2) = $cr->clip_extents;
        my $start_row = POSIX::floor($cy); my $end_row = POSIX::ceil($cy2);
        $start_row = 0 if $start_row < 0; $end_row = $vh if $end_row > $vh;
        my $rows = $end_row - $start_row;
        if ($rows > 0) {
            my $bpr = $vw * $bpp; my $offset = $bo + $start_row * $bpr; my $len = $rows * $bpr; my $total = length($raw);
            if ($offset < $total) {
                $len = $total - $offset if $offset + $len > $total;
                my $sliced = substr($raw, $offset, $len); my $actual_rows = POSIX::ceil(length($sliced) / $bpr);
                my $expected = $actual_rows * $bpr; $sliced .= "\0" x ($expected - length($sliced)) if length($sliced) < $expected;
                my ($pd, $alpha) = (undef, FALSE);
                if    ($fmt eq 'rgb24')    { $pd = $sliced; }
                elsif ($fmt eq 'rgba32')   { $pd = $sliced; $alpha = TRUE; }
                elsif ($fmt eq 'gray8')    { $pd = pack 'C*', map { ($_,$_,$_) } unpack 'C*', $sliced; }
                elsif ($fmt eq 'gray16le') { $pd = pack 'C*', map { my $v=int($_/257); ($v,$v,$v) } unpack 'v*', $sliced; }
                if (defined $pd && length($pd) > 0) {
                    my $out_bpr = $vw * ($alpha ? 4 : 3);
                    my $pb = eval { Gtk3::Gdk::Pixbuf->new_from_data($pd, 'rgb', $alpha, 8, $vw, $actual_rows, $out_bpr) };
                    if ($pb) {
                        $pb->{_slice} = \$pd;
                        if (Gtk3::Gdk->can('cairo_set_source_pixbuf')) { Gtk3::Gdk::cairo_set_source_pixbuf($cr, $pb, 0, $start_row); }
                        else { _cairo_set_source_pixbuf($cr, $pb, 0, $start_row); }
                        $cr->paint;
                    }
                }
            }
        }
        return FALSE;
    });
    my $update = sub {
        my $nw = int($w_adj->get_value); my $bo = int($off_adj->get_value);
        my $iter = $fmt_combo->get_active_iter; return unless $iter;
        my ($fmt, $bpp) = $fmt_combo->get_model->get($iter, 1, 2);
        return if $nw <= 0 || $bpp <= 0;
        if ($bo >= $sz - ($nw * $bpp)) { $bo = $sz - ($nw * $bpp); $bo = 0 if $bo < 0; $off_adj->set_value($bo); }
        my $nh = int((length($raw) - $bo) / ($nw * $bpp)); return if $nh <= 0;
        $decode_state->{vw} = $nw; $decode_state->{vh} = $nh; $decode_state->{bpp} = $bpp;
        $decode_state->{fmt} = $fmt; $decode_state->{bo} = $bo;
        $da->set_size_request($nw, $nh); $w_entry->set_text($nw);
        $win->set_title("Decoded Bitmap: $nw x $nh virtual px (Offset: $bo)"); $da->queue_draw();
    };
    $w_adj->signal_connect(value_changed => sub {
        if ($w_adj->get_value > $max_entry->get_text) { $max_entry->set_text(int($w_adj->get_value) + 512); $apply_min_max->(); }
        $update->();
    });
    $off_adj->signal_connect(value_changed => $update); $fmt_combo->signal_connect(changed => $update);
    $btn_save->signal_connect(clicked => sub {
        my $nw = $decode_state->{vw}; my $nh = $decode_state->{vh}; my $bpp = $decode_state->{bpp};
        my $fmt = $decode_state->{fmt}; my $bo = $decode_state->{bo};
        return unless $nw > 0 && $nh > 0;
        my $sliced = substr($raw, $bo, $nw * $nh * $bpp);
        my ($pd, $alpha) = (undef, FALSE);
        if    ($fmt eq 'rgb24')    { $pd = $sliced; }
        elsif ($fmt eq 'rgba32')   { $pd = $sliced; $alpha = TRUE; }
        elsif ($fmt eq 'gray8')    { $pd = pack 'C*', map { ($_,$_,$_) } unpack 'C*', $sliced; }
        elsif ($fmt eq 'gray16le') { $pd = pack 'C*', map { my $v=int($_/257); ($v,$v,$v) } unpack 'v*', $sliced; }
        return unless defined $pd && length($pd) > 0;
        my $full_pb = eval { Gtk3::Gdk::Pixbuf->new_from_data($pd, 'rgb', $alpha, 8, $nw, $nh, $nw * ($alpha ? 4 : 3)) };
        return unless $full_pb;
        my $chooser = Gtk3::FileChooserDialog->new("Save PNG", $win, 'save', 'Cancel' => 'cancel', 'Save' => 'accept');
        $chooser->set_current_name("bitmap_decode.png");
        if ($chooser->run eq 'accept') { my $fn = $chooser->get_filename; $fn .= ".png" unless $fn =~ /\.png$/i; eval { $full_pb->savev($fn, 'png', [], []); }; }
        $chooser->destroy;
    });
    $btn_find->signal_connect(clicked => sub {
        my $cur_min = $min_entry->get_text; my $cur_max = $max_entry->get_text;
        $cur_min = 1    unless $cur_min =~ /^\d+$/ && $cur_min >= 1;
        $cur_max = 8192 unless $cur_max =~ /^\d+$/ && $cur_max >= 1;
        ($cur_min, $cur_max) = ($cur_max, $cur_min) if $cur_min > $cur_max;
        my $pw = Gtk3::Window->new('toplevel'); $pw->set_title("Finding Best Width..."); $pw->set_transient_for($win);
        $pw->set_default_size(500, 350); $pw->set_position('center-on-parent');
        my $pvbox = Gtk3::Box->new('vertical', 4); $pvbox->set_border_width(6); $pw->add($pvbox);
        my $psw = Gtk3::ScrolledWindow->new(undef, undef); $psw->set_policy('automatic', 'automatic');
        my $ptv = Gtk3::TextView->new; $ptv->set_editable(FALSE);
        my $pbuf = $ptv->get_buffer; $pbuf->set_text("Scanning widths $cur_min .. $cur_max ...\n");
        $psw->add($ptv); $pvbox->pack_start($psw, TRUE, TRUE, 0);
        my $pbar = Gtk3::ProgressBar->new; my $cancel = Gtk3::Button->new_with_label("Cancel");
        my $phbox = Gtk3::Box->new('horizontal', 4);
        $phbox->pack_start($pbar, TRUE, TRUE, 0); $phbox->pack_start($cancel, FALSE, FALSE, 0);
        $pvbox->pack_start($phbox, FALSE, FALSE, 0); $pw->show_all;
        my $cancelled = 0; $cancel->signal_connect(clicked => sub { $cancelled = 1; });
        my $append = sub { my ($txt) = @_; $pbuf->insert($pbuf->get_end_iter, $txt); $ptv->scroll_to_iter($pbuf->get_end_iter, 0, FALSE, 0, 0); };
        my $total_steps = $cur_max - $cur_min + 1; my $steps_done = 0;
        my $best_w = -1; my $low_score = -1;
        my $raw_copy = $raw; my $sz_copy = $sz;
        my $pdl_data = PDL->pdl( unpack 'C*', $raw_copy );
        my $idle_id; my $cur_w = $cur_min;
        $idle_id = Glib::Idle->add(sub {
            if ($cancelled) { $append->("\nCancelled.\n"); $pbar->set_fraction(1.0); $cancel->set_label("Close"); $cancel->signal_connect(clicked => sub { $pw->destroy; }); return FALSE; }
            if ($cur_w > $cur_max) {
                if ($best_w > 0) {
                    $append->(sprintf("\nBest width: %d  (score %.2f)\n", $best_w, $low_score));
                    my $lo = $w_adj->get_lower; my $hi = $w_adj->get_upper - 1;
                    if ($best_w < $lo || $best_w > $hi) { $min_entry->set_text($best_w < $lo ? $best_w : int($lo)); $max_entry->set_text($best_w > $hi ? $best_w : int($hi)); $apply_min_max->(); }
                    $w_adj->set_value($best_w);
                } else { $append->("\nNo valid width found in range.\n"); }
                $pbar->set_fraction(1.0); $cancel->set_label("Close"); $cancel->signal_connect(clicked => sub { $pw->destroy; }); return FALSE;
            }
            my $w = $cur_w; my $num_pix = $sz_copy / 3;
            if ($w <= $num_pix) {
                my $h = int($num_pix / $w);
                if ($h >= 2) {
                    my $trimmed = $w * $h * 3; my $img_pdl = $pdl_data->slice("0:" . ($trimmed - 1));
                    my $reshaped = $img_pdl->reshape(3 * $w, $h);
                    my $score = sum(abs($reshaped->slice(":,0:-2") - $reshaped->slice(":,1:-1")));
                    my $norm = $score / $h;
                    $append->(sprintf("W:%4d H:%4d score:%.2f\n", $w, $h, $norm));
                    if ($low_score < 0 || $norm < $low_score) { $low_score = $norm; $best_w = $w; }
                }
            }
            $steps_done++; $cur_w++; $pbar->set_fraction($steps_done / $total_steps); return TRUE;
        });
        $pw->signal_connect(destroy => sub { $cancelled = 1; Glib::Source->remove($idle_id) if defined $idle_id; });
    });
    $win->signal_connect(destroy => sub { $decode_state->{vw} = 0; $decode_state->{vh} = 0; $decode_state->{fmt} = ''; $decode_state->{bo} = 0; $raw = ''; });
    $update->(); $win->set_default_size(800, 600); $win->show_all;
}

sub _find_executable {
    my ($cmd) = @_;
    return $cmd if ($cmd =~ m#/# && -x $cmd && !-d $cmd);
    for my $p (split(/:/, $ENV{PATH} || ''), '/usr/sbin', '/sbin') {
        my $f = File::Spec->catfile($p, $cmd); return $f if -x $f && !-d $f;
    }
    return;
}

sub on_photorec_click {
    my ($parent, $p_state) = @_;
    my $pr = _find_executable('photorec');
    unless ($pr) {
        my $d = Gtk3::MessageDialog->new($parent, 'destroy-with-parent', 'error', 'close', "Required command not found: 'photorec'");
        $d->run; $d->destroy; return;
    }
    my $user = $ENV{SUDO_USER};
    unless ($user) {
        my $d = Gtk3::MessageDialog->new($parent, 'destroy-with-parent', 'error', 'close', "Cannot determine original user via SUDO_USER.");
        $d->run; $d->destroy; return;
    }
    my ($uid, $gid) = (getpwnam $user)[2,3];
    my ($data_ref, $offs, undef, $path) = get_data_for_action($p_state);
    return unless defined $data_ref && $$data_ref;
    my $out_dir = File::Spec->catfile(cwd(), 'captures', _generate_filename($p_state, $offs, $path));
    eval { make_path($out_dir); chown $uid, $gid, $out_dir; };
    if ($@) { my $d = Gtk3::MessageDialog->new($parent, 'destroy-with-parent', 'error', 'close', "Cannot create output dir: $@"); $d->run; $d->destroy; return; }
    my ($fh, $tmp) = tempfile(UNLINK => 0); binmode $fh; print $fh $$data_ref; close $fh; chmod 0644, $tmp;
    my $win = Gtk3::Window->new('toplevel'); $win->set_title("photorec results"); $win->set_transient_for($parent); $win->set_default_size(600, 400);
    my $vbox = Gtk3::Box->new('vertical', 5); $win->add($vbox);
    my $sw = Gtk3::ScrolledWindow->new(undef, undef); $sw->set_policy('automatic', 'automatic'); $vbox->pack_start($sw, TRUE, TRUE, 0);
    my $tv = Gtk3::TextView->new(); $tv->set_editable(FALSE);
    my $buf = $tv->get_buffer; $buf->set_text("Starting photorec...\n"); $sw->add($tv); $win->show_all;
    my $args = 'partition_none,options,paranoid_no,keep_corrupted_file,wholespace,fileopt,everything,enable,gsm,disable,dovecot,disable,search';
    pipe(my $rd, my $wr); my $pid = fork(); die "Cannot fork: $!" unless defined $pid;
    if ($pid == 0) {
        close $rd; open STDOUT, '>&', $wr; open STDERR, '>&', $wr;
        exec('sudo', '-u', $user, $pr, '/d', "$out_dir/", '/log', '/cmd', $tmp, $args); die "Exec failed";
    }
    close $wr;
    my $io_id = Glib::IO->add_watch(fileno($rd), ['in', 'hup'], sub { my $l = <$rd>; return FALSE unless defined $l; $buf->insert($buf->get_end_iter, $l); return TRUE; });
    Glib::Child->watch_add($pid, sub { Glib::Source->remove($io_id) if defined $io_id; close($rd); unlink($tmp); $buf->insert($buf->get_end_iter, "\n--- Done ---\nCheck directory: $out_dir\n"); });
}

# ---------------------------------------------------------------------------
# Data Parsing & Status
# ---------------------------------------------------------------------------
sub parse_vram_mm {
    my ($source) = @_;
    my @lines;
    if (defined $source) {
        open my $fh, '<', $source or return 0;
        @lines = <$fh>; close $fh;
    } else {
        open my $fh, '<', $VRAM_MM_PATH or return 0;
        @lines = <$fh>; close $fh;
    }

    @{$state->{regions}} = ();
    $state->{total_pages} = $state->{used_pages} = $state->{free_pages} = 0;
    my ($total_bytes, $used_bytes) = (0, 0);

    {
        no warnings 'portable';
        for my $line (@lines) {
            chomp $line;
            # Old drm_mm format: per-region lines
            if ($line =~ /^(0x[0-9a-f]+)-(0x[0-9a-f]+):\s*(\d+):\s*(used|free)/i) {
                my ($s, $e, $sz, $st) = (hex($1), hex($2), $3+0, $4);
                push @{$state->{regions}}, { start=>$s, end=>$e, size=>$sz, status=>$st };
                $state->{total_pages} += $sz;
                if ($st eq 'used') { $state->{used_pages} += $sz; } else { $state->{free_pages} += $sz; }
            }
            # New buddy-allocator summary format
            elsif ($line =~ /^\s*size:\s*(\d+)/)  { $total_bytes = $1; }
            elsif ($line =~ /^\s*usage:\s*(\d+)/)  { $used_bytes  = $1; }
        }
    }

    # If no per-region data, synthesize two regions from summary stats
    if (!@{$state->{regions}} && $total_bytes > 0) {
        my $used_pages  = int($used_bytes  / 4096);
        my $total_pages = int($total_bytes / 4096);
        my $free_pages  = $total_pages - $used_pages;
        $used_pages = 0 if $used_pages < 0; $free_pages = 0 if $free_pages < 0;
        push @{$state->{regions}}, { start=>0,           end=>$used_pages-1,  size=>$used_pages, status=>'used' } if $used_pages > 0;
        push @{$state->{regions}}, { start=>$used_pages, end=>$total_pages-1, size=>$free_pages, status=>'free' } if $free_pages > 0;
        $state->{total_pages}     = $total_pages;
        $state->{used_pages}      = $used_pages;
        $state->{free_pages}      = $free_pages;
        $state->{vram_size_bytes} = $total_bytes;
    } else {
        $state->{vram_size_bytes} = $state->{regions}[-1]{end} * 4096 if @{$state->{regions}};
    }

    return scalar @{$state->{regions}};
}

sub parse_gem_info {
    my ($source) = @_;
    my @lines;
    if (defined $source) { open my $fh, '<', $source or return {}; @lines = <$fh>; close $fh; }
    else { open my $fh, '<', $GEM_INFO_PATH or return {}; @lines = <$fh>; close $fh; }
    my (%gem, $c_pid, $c_cmd);
    for my $line (@lines) {
        chomp $line;
        if ($line =~ /^pid\s+(\d+)\s+command\s+(\S+):/) { ($c_pid, $c_cmd) = ($1, $2); $gem{$c_pid} //= { cmd=>$c_cmd, bos=>[], total_vram=>0 }; }
        elsif (defined $c_pid && $line =~ /^\s+(0x[0-9a-f]+):\s+(\d+)\s+byte\s+(VRAM|GTT|CPU)/i) {
            my ($h, $b, $t) = ($1, $2+0, $3);
            my $f = ($line =~ /byte\s+\S+\s+(.+)/) ? $1 : '';
            push @{$gem{$c_pid}{bos}}, { handle=>$h, bytes=>$b, type=>$t, flags=>$f };
            $gem{$c_pid}{total_vram} += $b if $t eq 'VRAM';
        }
    }
    return \%gem;
}

sub push_status {
    my ($msg) = @_; debug("STATUS: $msg");
    if ($state->{statusbar}) { $state->{statusbar}->pop($state->{status_context}); $state->{statusbar}->push($state->{status_context}, $msg); }
}

sub make_status_text {
    return "No data" unless @{$state->{regions}};
    my $src = $state->{using_buddy_module} ? "buddy module" : "amdgpu_vram_mm";
    sprintf("VRAM %.1f GB | Used %.1f MB (%.0f%%) | %d regions | %s | src: %s",
        $state->{vram_size_bytes}/(1024**3),
        $state->{used_pages}*4096/(1024**2),
        100.0*$state->{used_pages}/($state->{total_pages}||1),
        scalar @{$state->{regions}},
        read_method_label(),
        $src);
}

sub hsv2rgb {
    my ($h, $s, $v) = @_;
    my $hi = int($h / 60); my $f = ($h / 60) - $hi;
    my $p = $v * (1 - $s); my $q = $v * (1 - $s * $f); my $t = $v * (1 - $s * (1 - $f));
    my ($r, $g, $b) = ($hi % 6 == 0) ? ($v, $t, $p) : ($hi % 6 == 1) ? ($q, $v, $p) : ($hi % 6 == 2) ? ($p, $v, $t) : ($hi % 6 == 3) ? ($p, $q, $v) : ($hi % 6 == 4) ? ($t, $p, $v) : ($v, $p, $q);
    return ($r, $g, $b);
}

sub format_bytes {
    my ($b) = @_; return "0 B" unless $b;
    my @u = qw(B KB MB GB TB); my $i = 0;
    while ($b >= 1024 && $i < $#u) { $b /= 1024; $i++; }
    sprintf "%.2f %s", $b, $u[$i];
}

# ---------------------------------------------------------------------------
# vramgaze_buddy parsing (needs $state and format_bytes, so defined here)
# ---------------------------------------------------------------------------

# Parse /sys/kernel/debug/vramgaze_buddy_nodes into the same region format
# that parse_vram_mm produces from the old drm_mm output.
#
# Buddy node format:
#   drm_buddy @ ffff...  size=2147483648  chunk=4096  n_roots=1
#   --- root[0] ---
#   node 0x000000000000: 1048576 bytes (256 pages), used
#   node 0x000000100000: 524288 bytes (128 pages), free
#
# We convert byte offsets + sizes to page numbers to match the existing
# region format which uses 4096-byte pages:
#   { start => page_start, end => page_end, size => num_pages, status => 'used'|'free' }
#
sub parse_buddy_nodes {
    my ($source) = @_;
    $source //= $BUDDY_NODES_PATH;

    open my $fh, '<', $source or return 0;
    my @lines = <$fh>;
    close $fh;

    @{$state->{regions}} = ();
    $state->{total_pages} = $state->{used_pages} = $state->{free_pages} = 0;
    $state->{vram_size_bytes} = 0;

    my $page_size = 4096;

    for my $line (@lines) {
        chomp $line;

        # Header line: drm_buddy @ addr  size=N  chunk=N  n_roots=N
        if ($line =~ /size=(\d+)/) {
            $state->{vram_size_bytes} = $1 if $1 > $state->{vram_size_bytes};
        }

        # Node line: node 0xOFFSET: BYTES bytes (PAGES pages), used|free
        if ($line =~ /node\s+(0x[0-9a-f]+):\s+(\d+)\s+bytes\s+\((\d+)\s+pages\),\s+(used|free)/i) {
            my ($offset_hex, $bytes, $pages, $status) = ($1, $2+0, $3+0, $4);
            my $offset     = hex($offset_hex);
            my $page_start = int($offset / $page_size);
            my $page_end   = $page_start + $pages - 1;

            push @{$state->{regions}}, {
                start  => $page_start,
                end    => $page_end,
                size   => $pages,
                status => $status,
            };
            $state->{total_pages} += $pages;
            if ($status eq 'used') { $state->{used_pages} += $pages; }
            else                   { $state->{free_pages} += $pages; }
        }
    }

    # If we got regions but no size from header, derive from last region
    if (!$state->{vram_size_bytes} && @{$state->{regions}}) {
        $state->{vram_size_bytes} = ($state->{regions}[-1]{end} + 1) * $page_size;
    }

    my $n = scalar @{$state->{regions}};
    debug(sprintf("parse_buddy_nodes: %d regions, %s total, %s used, %s free",
        $n,
        format_bytes($state->{vram_size_bytes}),
        format_bytes($state->{used_pages} * $page_size),
        format_bytes($state->{free_pages} * $page_size))) if $n;

    return $n;
}

sub build_region_tooltip {
    my ($r, $idx) = @_;
    my $bytes = $r->{size} * 4096;
    my $src   = $state->{using_buddy_module} ? " [buddy]" : "";
    my $tt = sprintf("Region %d%s\n0x%x-0x%x\n%s\n%s",
        $idx, $src, $r->{start}, $r->{end},
        $r->{status} ? uc($r->{status}) : 'UNKNOWN', format_bytes($bytes));
    if ($r->{status} eq 'used') {
        my $owners = $state->{gem_map_by_size}{$bytes};
        if ($owners && @$owners) {
            my %seen; $seen{"$_->{cmd} (PID $_->{pid})"}++ for @$owners;
            my @sorted = sort { $seen{$b} <=> $seen{$a} } keys %seen;
            $tt .= "\n\nPotential Owners (Heuristic match by size):";
            my $limit = 6;
            for my $i (0 .. $#sorted) { last if $i >= $limit; $tt .= sprintf("\n  %s  [%d BOs]", $sorted[$i], $seen{$sorted[$i]}); }
            $tt .= sprintf("\n  ...and %d more processes", scalar(@sorted) - $limit) if @sorted > $limit;
        } else { $tt .= "\n\n(No matching VRAM BOs found in GEM info)"; }
    }
    return $tt;
}

# ---------------------------------------------------------------------------
# GEM BO Strip -- proportional size map of all VRAM buffer objects
# ---------------------------------------------------------------------------
sub draw_gem_bo_strip {
    my ($cr, $W, $y_start, $strip_h) = @_;
    my $pad = 10; my $usable = $W - $pad * 2;
    my @bos; my $total_vram_bytes = 0;
    for my $pid (sort { $a <=> $b } keys %{$state->{gem_data}}) {
        my $g = $state->{gem_data}{$pid};
        for my $bo (@{$g->{bos}}) {
            next unless uc($bo->{type}) eq 'VRAM';
            push @bos, { pid => $pid, cmd => $g->{cmd}, bytes => $bo->{bytes}, type => $bo->{type}, flags => $bo->{flags} // '' };
            $total_vram_bytes += $bo->{bytes};
        }
    }
    return unless $total_vram_bytes > 0;
    $cr->set_source_rgb(@{$C{text_fg}}); $cr->select_font_face('monospace', 'normal', 'normal'); $cr->set_font_size(9);
    $cr->move_to($pad, $y_start - 4);
    $cr->show_text(sprintf("GEM BO Map  |  %d VRAM BOs  |  %s allocated  |  position unknown, size proportional", scalar @bos, format_bytes($total_vram_bytes)));
    $state->{gem_strip_areas} = []; my $x = $pad;
    for my $bo (@bos) {
        my $w = ($bo->{bytes} / $total_vram_bytes) * $usable; $w = 1 if $w < 1;
        my $hue = ($bo->{pid} * 137.508) % 360; my ($r, $g, $b) = hsv2rgb($hue, 0.75, 0.85);
        $cr->set_source_rgb($r, $g, $b); $cr->rectangle($x, $y_start, $w, $strip_h); $cr->fill;
        push @{$state->{gem_strip_areas}}, { x => $x, y => $y_start, w => $w, h => $strip_h, bo => $bo };
        $x += $w;
    }
    my $vram_total = $state->{vram_size_bytes} || $total_vram_bytes;
    if ($vram_total > $total_vram_bytes) {
        my $used_w = ($total_vram_bytes / $vram_total) * $usable;
        $cr->set_source_rgb(@{$C{free}}); $cr->rectangle($pad + $used_w, $y_start, $usable - $used_w, $strip_h); $cr->fill;
    }
    $cr->set_source_rgb(@{$C{grid}}); $cr->set_line_width(1); $cr->rectangle($pad, $y_start, $usable, $strip_h); $cr->stroke;
}

# ---------------------------------------------------------------------------
# Hilbert Map Generation & View
# ---------------------------------------------------------------------------
sub _hilbert_rot {
    my ($n, $x, $y, $rx, $ry) = @_;
    if ($ry == 0) { if ($rx == 1) { $x=$n-1-$x; $y=$n-1-$y; } return ($y, $x); }
    return ($x, $y);
}
sub d2xy {
    my ($n, $d) = @_; my ($x, $y) = (0, 0); my $s = 1;
    while ($s < $n) { my $rx = 1 & ($d >> 1); my $ry = 1 & ($d ^ $rx); ($x, $y) = _hilbert_rot($s, $x, $y, $rx, $ry); $x += $s*$rx; $y += $s*$ry; $d >>= 2; $s <<= 1; }
    return ($x, $y);
}
sub xy2d {
    my ($n, $x, $y) = @_; my $d = 0; my $s = $n >> 1;
    while ($s > 0) { my $rx = ($x & $s) ? 1 : 0; my $ry = ($y & $s) ? 1 : 0; $d += $s*$s*((3*$rx)^$ry); ($x, $y) = _hilbert_rot($s, $x, $y, $rx, $ry); $s >>= 1; }
    return $d;
}
sub generate_hilbert_path {
    my ($order) = @_; my $N = 2**$order; my @path;
    push @path, [d2xy($N, $_)] for 0 .. $N*$N-1;
    return \@path;
}

sub _render_hilbert {
    my ($cr, $h_state) = @_;
    $cr->set_source_rgb(@{$C{bg}}); $cr->paint;
    return unless @{$state->{regions}} && $state->{total_pages};
    my ($order, $scale, $padding, $path) = @{$h_state}{qw/order scale padding path/};
    my $side = 2**$order; my $total_cells = $side * $side;
    my $ppc = $state->{total_pages} / $total_cells; $ppc = 1 if $ppc < 1;
    my (@cells, $reg_i, $reg_rem);
    $reg_i = 0; $reg_rem = @{$state->{regions}} ? $state->{regions}[0]{size} : 0;
    for my $c (0 .. $total_cells-1) {
        my $need = $ppc; my ($up, $fp, $first) = (0, 0, $reg_i);
        while ($need > 0 && $reg_i < @{$state->{regions}}) {
            my $take = ($reg_rem < $need) ? $reg_rem : $need;
            if ($state->{regions}[$reg_i]{status} eq 'used') { $up += $take; } else { $fp += $take; }
            $need -= $take; $reg_rem -= $take;
            if ($reg_rem <= 0) { $reg_i++; $reg_rem = ($reg_i < @{$state->{regions}}) ? $state->{regions}[$reg_i]{size} : 0; }
        }
        push @cells, { used=>$up, free=>$fp, reg=>$first };
    }
    $h_state->{cells} = \@cells;
    for my $d (0 .. $total_cells-1) {
        my ($gx, $gy) = @{$path->[$d]};
        my $c = $cells[$d]; my $tot = $c->{used} + $c->{free}; next if $tot == 0;
        my $frac = $c->{used} / $tot;
        my @col = map { $C{free}[$_] + $frac*($C{used}[$_]-$C{free}[$_]) } (0,1,2);
        $cr->set_source_rgb(@col);
        $cr->rectangle($padding+$gx*$scale, $padding+$gy*$scale, ($scale>1?$scale:1), ($scale>1?$scale:1)); $cr->fill;
    }
}

sub show_hilbert_view {
    my ($parent) = @_;
    if (defined $state->{hilbert_window_ref}) { $state->{hilbert_window_ref}->get_toplevel->present; return; }
    my ($order, $scale, $padding) = (8, 2, 10);
    my $side = 2**$order; my $canvas = $side * $scale + $padding * 2;
    my $popup = Gtk3::Window->new('toplevel'); $popup->set_title("VRAM Hilbert Map"); $popup->set_transient_for($parent); $popup->set_default_size($canvas, $canvas + 60);
    my $vbox = Gtk3::Box->new('vertical', 2); my $toolbar = Gtk3::Box->new('horizontal', 4);
    my $btn_ref = Gtk3::Button->new_with_label("Refresh"); my $lbl_method = Gtk3::Label->new("  " . read_method_label());
    $toolbar->pack_start($btn_ref, FALSE, FALSE, 2); $toolbar->pack_start($lbl_method, FALSE, FALSE, 4);
    $vbox->pack_start($toolbar, FALSE, FALSE, 2); $popup->add($vbox);
    my $h_state = { order => $order, scale => $scale, padding => $padding, path => generate_hilbert_path($order), backing_surface => undef, cells => [], hover_region => undef };
    $state->{hilbert_state} = $h_state;
    my $da = Gtk3::DrawingArea->new; $da->set_size_request($canvas, $canvas); $da->set_has_tooltip(TRUE);
    $da->set_events(['pointer-motion-mask', 'button-press-mask']);
    $da->signal_connect('size-allocate' => sub { $h_state->{backing_surface} = undef; return FALSE; });
    $da->signal_connect('draw' => sub {
        my ($widget, $cr) = @_;
        unless (defined $h_state->{backing_surface}) {
            my $w = $widget->get_allocated_width; my $h = $widget->get_allocated_height;
            $h_state->{backing_surface} = Cairo::ImageSurface->create('rgb24', $w, $h);
            my $bcr = Cairo::Context->create($h_state->{backing_surface}); _render_hilbert($bcr, $h_state);
        }
        $cr->set_source_surface($h_state->{backing_surface}, 0, 0); $cr->paint;
        my $s_off = $state->{range_start_offset}; my $e_off = $state->{range_end_offset};
        for my $d (0 .. $side*$side-1) {
            next unless defined $h_state->{cells}[$d];
            my $reg = $h_state->{cells}[$d]{reg};
            next unless defined $reg && $reg < scalar @{$state->{regions}};
            next unless defined $state->{regions}[$reg]{start};
            my ($gx,$gy) = @{$h_state->{path}[$d]}; my $draw = 0;
            my $r_s = $state->{regions}[$reg]{start} * 4096; my $r_e = ($state->{regions}[$reg]{end} + 1) * 4096;
            if (defined $s_off && defined $e_off) { if ($r_s < $e_off && $r_e > $s_off) { $cr->set_source_rgba(0.9, 0.2, 0.2, 0.8); $draw = 1; } }
            elsif (defined $state->{range_anchor_offset}) { if ($state->{range_anchor_offset} >= $r_s && $state->{range_anchor_offset} < $r_e) { $cr->set_source_rgba(0.2, 0.9, 0.2, 0.8); $draw = 1; } }
            elsif (defined $h_state->{hover_region} && $reg == $h_state->{hover_region}) { $cr->set_source_rgba(@{$C{hi}}, 0.8); $draw = 1; }
            if ($draw) { $cr->rectangle($padding+$gx*$scale, $padding+$gy*$scale, ($scale>1?$scale:1), ($scale>1?$scale:1)); $cr->fill; }
        }
        return FALSE;
    });
    $da->signal_connect('motion-notify-event' => sub {
        my ($widget, $event) = @_;
        my $gx = POSIX::floor(($event->x - $padding) / $scale); my $gy = POSIX::floor(($event->y - $padding) / $scale);
        if ($gx >= 0 && $gx < $side && $gy >= 0 && $gy < $side) {
            my $d = xy2d($side, $gx, $gy);
            if (defined $h_state->{cells} && $d < @{$h_state->{cells}}) {
                my $ri = $h_state->{cells}[$d]{reg};
                if (!defined $h_state->{hover_region} || $h_state->{hover_region} != $ri) { $h_state->{hover_region} = $ri; $widget->queue_draw; }
            }
        } else { if (defined $h_state->{hover_region}) { $h_state->{hover_region} = undef; $widget->queue_draw; } }
        return FALSE;
    });
    $da->signal_connect('query-tooltip' => sub {
        my ($widget, $x, $y, $kb, $tooltip) = @_;
        my $gx = POSIX::floor(($x-$padding)/$scale); my $gy = POSIX::floor(($y-$padding)/$scale);
        return FALSE if $gx<0||$gx>=$side||$gy<0||$gy>=$side;
        my $d = xy2d($side,$gx,$gy);
        return FALSE unless defined $h_state->{cells} && $d < @{$h_state->{cells}};
        my $c = $h_state->{cells}[$d]; my $ri = $c->{reg};
        return FALSE unless $ri < @{$state->{regions}};
        $tooltip->set_text(build_region_tooltip($state->{regions}[$ri], $ri)); return TRUE;
    });
    $da->signal_connect('button-press-event' => sub {
        my ($widget, $event) = @_;
        return FALSE unless $event->button == 1 || $event->button == 3;
        my $gx = POSIX::floor(($event->x - $padding) / $scale); my $gy = POSIX::floor(($event->y - $padding) / $scale);
        return FALSE unless $gx>=0 && $gx<$side && $gy>=0 && $gy<$side;
        my $d = xy2d($side, $gx, $gy);
        return FALSE unless defined $h_state->{cells} && $d < @{$h_state->{cells}};
        my $c = $h_state->{cells}[$d]; my $ri = $c->{reg};
        return FALSE unless $ri < @{$state->{regions}};
        my $clicked_start_offset = $state->{regions}[$ri]{start} * 4096;
        my $clicked_end_offset   = ($state->{regions}[$ri]{end} + 1) * 4096;
        if ($event->button == 1) {
            $state->{range_anchor_offset} = $clicked_start_offset; $state->{range_start_offset} = $state->{range_end_offset} = undef;
            $widget->queue_draw; $state->{drawing_area}->queue_draw if defined $state->{drawing_area};
            push_status(sprintf("Selection start set to 0x%x (Region %d)", $clicked_start_offset, $ri)); return TRUE;
        }
        if ($event->button == 3) {
            unless (-r $AMDGPU_VRAM_PATH) { push_status("No read method available -- run as root"); return TRUE; }
            my $start_offset = defined $state->{range_anchor_offset} ? $state->{range_anchor_offset} : $clicked_start_offset;
            my $end_offset   = defined $state->{range_anchor_offset} ? $clicked_end_offset : $clicked_end_offset;
            if ($start_offset > $end_offset) { ($start_offset, $end_offset) = ($clicked_start_offset, $start_offset); }
            $state->{range_start_offset} = $start_offset; $state->{range_end_offset} = $end_offset; $state->{range_anchor_offset} = undef;
            my $read_size = $end_offset - $start_offset; my $max_read = 256 * 1024 * 1024;
            if ($read_size > $max_read) { $read_size = $max_read; $end_offset = $start_offset + $read_size; push_status("Selection too large, truncating to 256MB"); }
            else { push_status(sprintf("Reading %s at offset 0x%x via debugfs...", format_bytes($read_size), $start_offset)); }
            my ($s_idx, $e_idx) = get_indices_for_offsets($start_offset, $end_offset);
            Glib::Idle->add(sub {
                my $start_time = time(); my $raw_ref = read_vram($start_offset, $read_size); my $elapsed = time() - $start_time;
                if (defined $raw_ref && length($$raw_ref) > 0) {
                    my $map_info = build_multi_region_map_info($s_idx, $e_idx, $start_offset, length($$raw_ref));
                    my $title_str = ($s_idx == $e_idx) ? "Region $s_idx" : "Regions $s_idx - $e_idx";
                    show_vram_image_popup($parent, $raw_ref, { title => $title_str, pid => "GPU" }, $map_info);
                    push_status(sprintf("Loaded %s in %.3fs [%s]", format_bytes(length($$raw_ref)), $elapsed, $state->{read_method}));
                } else { push_status("Read failed - check you are running as root"); }
                return FALSE;
            });
            $widget->queue_draw; $state->{drawing_area}->queue_draw if defined $state->{drawing_area}; return TRUE;
        }
        return FALSE;
    });
    my $sw = Gtk3::ScrolledWindow->new(undef, undef); $sw->set_policy('automatic', 'automatic'); $sw->add($da); $vbox->pack_start($sw, TRUE, TRUE, 0);
    my $hbar = Gtk3::Statusbar->new; my $hctx = $hbar->get_context_id("hilbert"); $vbox->pack_start($hbar, FALSE, FALSE, 0);
    $btn_ref->signal_connect(clicked => sub {
        parse_vram_mm($VRAM_FILE); $h_state->{backing_surface} = undef; $da->queue_draw;
        $hbar->pop($hctx); $hbar->push($hctx, make_status_text()); $lbl_method->set_text("  " . read_method_label());
    });
    $hbar->push($hctx, make_status_text());
    $state->{hilbert_window_ref} = $da;
    $popup->signal_connect(destroy => sub { $state->{hilbert_window_ref} = $state->{hilbert_state} = undef; });
    $popup->show_all;
}

# ---------------------------------------------------------------------------
# Extra Windows (GEM Info, Sensors)
# ---------------------------------------------------------------------------
sub show_gem_window {
    my ($parent) = @_;
    my $gem = parse_gem_info($GEM_FILE);
    my $win = Gtk3::Window->new('toplevel'); $win->set_title("GEM Buffer Objects"); $win->set_transient_for($parent); $win->set_default_size(700, 500);
    my $vbox = Gtk3::Box->new('vertical', 4); $vbox->set_border_width(5); $win->add($vbox);
    my $store = Gtk3::ListStore->new('Glib::Int','Glib::String','Glib::String','Glib::Int');
    my $tv = Gtk3::TreeView->new($store);
    for (['PID',0],['Command',1],['VRAM',2],['BOs',3]) {
        my $c = Gtk3::TreeViewColumn->new_with_attributes($_->[0], Gtk3::CellRendererText->new, text => $_->[1]);
        $c->set_sort_column_id($_->[1]); $tv->append_column($c);
    }
    my $total = 0;
    for my $pid (sort { $a <=> $b } keys %$gem) {
        my $g = $gem->{$pid}; $total += $g->{total_vram};
        $store->set($store->append, 0,$pid, 1,$g->{cmd}, 2,format_bytes($g->{total_vram}), 3,scalar @{$g->{bos}});
    }
    my $detail = Gtk3::TextView->new; $detail->set_editable(FALSE);
    my $font_desc = Pango::FontDescription->new; $font_desc->set_family('monospace'); $font_desc->set_size(9 * Pango::SCALE); $detail->modify_font($font_desc);
    $tv->get_selection->signal_connect(changed => sub {
        my ($sel) = @_; my ($m, $i) = $sel->get_selected; return unless $i;
        my $pid = $m->get($i, 0); my $g = $gem->{$pid} or return;
        my $txt = sprintf("PID %d  (%s)  VRAM: %s\n%s\n", $pid, $g->{cmd}, format_bytes($g->{total_vram}), "-"x60);
        $txt .= sprintf(" %-12s %10s  %-4s  %s\n", $_->{handle}, format_bytes($_->{bytes}), $_->{type}, $_->{flags}//'') for @{$g->{bos}};
        $detail->get_buffer->set_text($txt);
    });
    my $paned = Gtk3::Paned->new('vertical');
    my $sw1 = Gtk3::ScrolledWindow->new(undef, undef); $sw1->add($tv);
    my $sw2 = Gtk3::ScrolledWindow->new(undef, undef); $sw2->add($detail);
    $paned->add1($sw1); $paned->add2($sw2); $paned->set_position(240);
    $vbox->pack_start($paned, TRUE, TRUE, 0);
    $vbox->pack_start(Gtk3::Label->new("Total: ".format_bytes($total)), FALSE, FALSE, 4);
    $win->signal_connect(destroy => sub { %$gem = (); });
    $win->show_all;
}

sub show_sensors_window {
    my ($parent) = @_;
    my $win = Gtk3::Window->new('toplevel'); $win->set_title("amdgpu Sensors"); $win->set_transient_for($parent); $win->set_default_size(420, 380);
    my $vbox = Gtk3::Box->new('vertical', 4); $vbox->set_border_width(5); $win->add($vbox);
    my $btn = Gtk3::Button->new_with_label("Refresh"); $vbox->pack_start($btn, FALSE, FALSE, 2);
    my $tv = Gtk3::TextView->new; $tv->set_editable(FALSE);
    my $font_desc = Pango::FontDescription->new; $font_desc->set_family('monospace'); $font_desc->set_size(9 * Pango::SCALE); $tv->modify_font($font_desc);
    my $sw = Gtk3::ScrolledWindow->new(undef, undef); $sw->add($tv); $vbox->pack_start($sw, TRUE, TRUE, 0);
    my $update = sub {
        my $t = `cat /sys/kernel/debug/dri/$DRI_INDEX/amdgpu_sensors 2>/dev/null` || `cat /sys/kernel/debug/dri/$DRI_INDEX/amdgpu_pm_info 2>/dev/null` || "N/A";
        $tv->get_buffer->set_text($t);
    };
    $btn->signal_connect(clicked => $update); $update->(); $win->show_all;
}

# ---------------------------------------------------------------------------
# Main UI Draw
# ---------------------------------------------------------------------------
my $hover_idx = undef;
my $hover_gtt = undef;
my $hover_gem = undef;

sub on_draw_main {
    my ($widget, $cr) = @_;
    my $W = $widget->get_allocated_width; my $H = $widget->get_allocated_height;
    $cr->set_source_rgb(@{$C{bg}}); $cr->paint;
    unless (@{$state->{regions}}) {
        $cr->set_source_rgb(@{$C{text_fg}}); $cr->select_font_face('Sans','normal','normal'); $cr->set_font_size(13);
        my $msg = "No data -- click Refresh";
        my $ex = $cr->text_extents($msg); $cr->move_to(($W-$ex->{width})/2, $H/2); $cr->show_text($msg); return FALSE;
    }
    my ($pad, $strip_h, $strip_y) = (10, 60, 22);
    my $gtt_h = 15; my $gtt_y = $strip_y + $strip_h + 16;

    # Source label
    my $src_label = $state->{using_buddy_module}
        ? "buddy module (per-block)"
        : "amdgpu_vram_mm";
    $cr->set_source_rgb(@{$C{text_fg}}); $cr->select_font_face('monospace','normal','normal'); $cr->set_font_size(9);
    $cr->move_to($pad, $strip_y - 4);
    $cr->show_text(sprintf("VRAM %.1f GB  |  src: %s  |  L-Click=Start  R-Click=Open  |  Read: %s",
        $state->{vram_size_bytes}/(1024**3), $src_label, read_method_label()));

    $state->{map_areas} = [];
    my $tp = $state->{total_pages} || 1;
    for my $i (0 .. $#{$state->{regions}}) {
        my $r = $state->{regions}[$i];
        next unless defined $r->{start} && defined $r->{status};
        my $x0 = $pad + $r->{start} / $tp * ($W-$pad*2);
        my $x1 = $pad + $r->{end}   / $tp * ($W-$pad*2);
        $x1 = $x0 + 1 if $x1-$x0 < 1;
        my @col = (defined $hover_idx && $i==$hover_idx) ? @{$C{hi}} : ($r->{status} eq 'used') ? @{$C{used}} : @{$C{free}};
        $cr->set_source_rgb(@col); $cr->rectangle($x0, $strip_y, $x1-$x0, $strip_h); $cr->fill;
        push @{$state->{map_areas}}, { x=>$x0, w=>$x1-$x0, y=>$strip_y, h=>$strip_h, idx=>$i, data=>$r };
    }

    if (defined $state->{range_start_offset} && defined $state->{range_end_offset}) {
        my $s_page = $state->{range_start_offset} / 4096; my $e_page = $state->{range_end_offset} / 4096;
        my $x0 = $pad + ($s_page / $tp) * ($W - $pad*2); my $x1 = $pad + ($e_page / $tp) * ($W - $pad*2);
        $cr->set_source_rgba(0.9, 0.2, 0.2, 0.6); $cr->rectangle($x0, $strip_y, $x1-$x0, $strip_h); $cr->fill;
    } elsif (defined $state->{range_anchor_offset}) {
        my $s_page = $state->{range_anchor_offset} / 4096;
        my $x0 = $pad + ($s_page / $tp) * ($W - $pad*2);
        $cr->set_source_rgba(0.2, 0.9, 0.2, 0.9); $cr->set_line_width(2.0);
        $cr->move_to($x0, $strip_y); $cr->line_to($x0, $strip_y + $strip_h); $cr->stroke;
    }
    $cr->set_source_rgb(@{$C{grid}}); $cr->set_line_width(1); $cr->rectangle($pad, $strip_y, $W-$pad*2, $strip_h); $cr->stroke;

    # GTT strip
    my $gtt_strip_drawn = FALSE;
    if ($state->{chk_gtt} && $state->{chk_gtt}->get_active && $state->{gtt_by_pid} && %{$state->{gtt_by_pid}}) {
        my $gtt_used = 0; $gtt_used += $_->{bytes} for values %{$state->{gtt_by_pid}};
        my $x = $pad; my $usable_width = $W - $pad*2;
        my @pids = sort { $state->{gtt_by_pid}{$b}{bytes} <=> $state->{gtt_by_pid}{$a}{bytes} } keys %{$state->{gtt_by_pid}};
        $state->{gtt_map_areas} = [];
        for my $pid (@pids) {
            my $info = $state->{gtt_by_pid}{$pid}; my $w = ($info->{bytes} / $state->{gtt_total}) * $usable_width; $w = 1 if $w < 1;
            my $hue = ($pid * 137.508) % 360; my ($r, $g, $b) = hsv2rgb($hue, 0.8, 0.9);
            $cr->set_source_rgb($r, $g, $b); $cr->rectangle($x, $gtt_y, $w, $gtt_h); $cr->fill;
            push @{$state->{gtt_map_areas}}, { x=>$x, y=>$gtt_y, w=>$w, h=>$gtt_h, pid=>$pid, cmd=>$info->{cmd}, bytes=>$info->{bytes} };
            $x += $w;
        }
        $cr->set_source_rgb(@{$C{grid}}); $cr->set_line_width(1); $cr->rectangle($pad, $gtt_y, $W-$pad*2, $gtt_h); $cr->stroke;
        if (defined $hover_gtt && $hover_gtt < scalar @{$state->{gtt_map_areas}}) {
            my $ha = $state->{gtt_map_areas}[$hover_gtt];
            $cr->set_source_rgba(@{$C{hi}}, 0.55); $cr->rectangle($ha->{x}, $ha->{y}, $ha->{w}, $ha->{h}); $cr->fill;
            $cr->set_source_rgb(@{$C{hi}}); $cr->set_line_width(1.5); $cr->rectangle($ha->{x}, $ha->{y}, $ha->{w}, $ha->{h}); $cr->stroke;
        }
        my $label = sprintf("GTT: %s / %s", format_bytes($gtt_used), format_bytes($state->{gtt_total}));
        $cr->select_font_face('Sans', 'normal', 'normal'); $cr->set_font_size(9);
        my $te = $cr->text_extents($label); my $tx = $pad + $usable_width - $te->{width} - 4; my $ty = $gtt_y + $gtt_h - 3;
        $cr->set_source_rgba(0, 0, 0, 0.8); $cr->move_to($tx + 1, $ty + 1); $cr->show_text($label);
        $cr->set_source_rgb(1, 1, 1); $cr->move_to($tx, $ty); $cr->show_text($label);
        $gtt_strip_drawn = TRUE;
    }

    my $below_y = $gtt_strip_drawn ? $gtt_y + $gtt_h : $strip_y + $strip_h;

    if (defined $hover_idx && $hover_idx < @{$state->{regions}}) {
        my $r = $state->{regions}[$hover_idx];
        $cr->set_source_rgb(@{$C{hi}}); $cr->set_font_size(9);
        $cr->move_to($pad, $below_y + 13);
        $cr->show_text(sprintf("Region %d 0x%x-0x%x [%s] %s", $hover_idx, $r->{start}, $r->{end}, $r->{status} ? uc($r->{status}) : 'UNKNOWN', format_bytes($r->{size}*4096)));
    }
    if (defined $hover_gtt && $state->{gtt_map_areas} && $hover_gtt < scalar @{$state->{gtt_map_areas}}) {
        my $ha = $state->{gtt_map_areas}[$hover_gtt];
        $cr->set_source_rgb(@{$C{hi}}); $cr->set_font_size(9);
        $cr->move_to($pad, $gtt_y + $gtt_h + 11);
        $cr->show_text(sprintf("GTT  PID %d  (%s)  %s  (%.1f%%)", $ha->{pid}, $ha->{cmd}, format_bytes($ha->{bytes}), 100.0 * $ha->{bytes} / ($state->{gtt_total} || 1)));
    }

    # GEM BO strip
    my $gem_strip_h = 30; my $gem_strip_y = $below_y + 28;
    if ($state->{gem_strip_visible} && $state->{gem_data} && %{$state->{gem_data}}) {
        draw_gem_bo_strip($cr, $W, $gem_strip_y, $gem_strip_h);
        if (defined $hover_gem && $state->{gem_strip_areas} && $hover_gem < scalar @{$state->{gem_strip_areas}}) {
            my $ha = $state->{gem_strip_areas}[$hover_gem];
            $cr->set_source_rgba(@{$C{hi}}, 0.55); $cr->rectangle($ha->{x}, $ha->{y}, $ha->{w}, $ha->{h}); $cr->fill;
            $cr->set_source_rgb(@{$C{hi}}); $cr->set_line_width(1.5); $cr->rectangle($ha->{x}, $ha->{y}, $ha->{w}, $ha->{h}); $cr->stroke;
            my $bo = $ha->{bo}; $cr->set_source_rgb(@{$C{hi}}); $cr->set_font_size(9);
            $cr->move_to($pad, $gem_strip_y + $gem_strip_h + 13);
            $cr->show_text(sprintf("PID %d (%s)  %s  %s  %s", $bo->{pid}, $bo->{cmd}, format_bytes($bo->{bytes}), $bo->{type}, $bo->{flags}));
        }
    }

    my $stats_base_y = $state->{gem_strip_visible} ? $gem_strip_y + $gem_strip_h + 28 : $below_y + 28;
    my ($key_x, $key_y, $bsz, $lh) = ($pad, $stats_base_y, 13, 19);
    $cr->select_font_face('Sans','normal','normal'); $cr->set_font_size(11);
    for my $item ([$C{used},"Used VRAM"], [$C{free},"Free VRAM"], [[0.2,0.9,0.2],"Start"], [[0.9,0.2,0.2],"Range"], [$C{hi},"Hover"]) {
        $cr->set_source_rgb(@{$item->[0]}); $cr->rectangle($key_x, $key_y, $bsz, $bsz); $cr->fill;
        $cr->set_source_rgb(@{$C{text_fg}}); $cr->move_to($key_x+$bsz+5, $key_y+$bsz-1); $cr->show_text($item->[1]); $key_y += $lh;
    }
    my ($sx, $sy) = ($W/2, $stats_base_y);
    $cr->set_source_rgb(@{$C{text_fg}}); $cr->select_font_face('Sans','normal','bold'); $cr->set_font_size(11);
    $cr->move_to($sx,$sy); $cr->show_text("VRAM: ".format_bytes($state->{vram_size_bytes})); $sy+=20;
    $cr->select_font_face('Sans','normal','normal');
    for my $row (
        sprintf("Used:  %s (%.1f%%)", format_bytes($state->{used_pages}*4096), 100.0*$state->{used_pages}/($state->{total_pages}||1)),
        sprintf("Free:  %s", format_bytes($state->{free_pages}*4096)),
        sprintf("Regions: %d  (%d used / %d free)", scalar @{$state->{regions}}, scalar(grep{$_->{status}eq'used'}@{$state->{regions}}), scalar(grep{$_->{status}eq'free'}@{$state->{regions}})),
        sprintf("Frag: %.1f%%  |  src: %s", @{$state->{regions}} ? 100.0*scalar(grep{$_->{status}eq'free'}@{$state->{regions}})/scalar @{$state->{regions}} : 0, $src_label)
    ) { $cr->move_to($sx,$sy); $cr->show_text($row); $sy+=17; }
    return FALSE;
}

sub on_query_tooltip_main {
    my ($widget, $x, $y, $kb, $tooltip) = @_;
    for my $a (@{$state->{map_areas}}) {
        if ($x>=$a->{x} && $x<=$a->{x}+$a->{w} && $y>=$a->{y} && $y<=$a->{y}+$a->{h}) { $tooltip->set_text(build_region_tooltip($a->{data}, $a->{idx})); return TRUE; }
    }
    if ($state->{gtt_map_areas}) {
        for my $a (@{$state->{gtt_map_areas}}) {
            if ($x>=$a->{x} && $x<=$a->{x}+$a->{w} && $y>=$a->{y} && $y<=$a->{y}+$a->{h}) {
                $tooltip->set_text(sprintf("GTT allocation\nPID:     %d\nProcess: %s\nGTT used: %s (%.1f%% of pool)", $a->{pid}, $a->{cmd}, format_bytes($a->{bytes}), 100.0 * $a->{bytes} / ($state->{gtt_total} || 1)));
                return TRUE;
            }
        }
    }
    if ($state->{gem_strip_areas}) {
        for my $a (@{$state->{gem_strip_areas}}) {
            if ($x>=$a->{x} && $x<=$a->{x}+$a->{w} && $y>=$a->{y} && $y<=$a->{y}+$a->{h}) {
                my $bo = $a->{bo};
                $tooltip->set_text(sprintf("PID: %d\nProcess: %s\nSize: %s\nType: %s\nFlags: %s", $bo->{pid}, $bo->{cmd}, format_bytes($bo->{bytes}), $bo->{type}, $bo->{flags}));
                return TRUE;
            }
        }
    }
    return FALSE;
}

# ---------------------------------------------------------------------------
# main()
# ---------------------------------------------------------------------------
sub main {
    debug("=== vramgaze3 starting  asic=$ASIC  dri=$DRI_INDEX ===");

    # Check for buddy module at startup
    if (buddy_module_available()) {
        debug("vramgaze_buddy module detected at $BUDDY_NODES_PATH");
    } else {
        debug("vramgaze_buddy module not present -- using amdgpu_vram_mm fallback");
    }

    my $window = Gtk3::Window->new('toplevel');
    $window->set_title("vramgaze  --  AMD VRAM Visualizer  [$ASIC]");
    $window->set_default_size(900, 380);
    $window->set_border_width(5);
    $window->signal_connect(destroy => sub { Gtk3->main_quit; });

    my $vbox = Gtk3::Box->new('vertical', 4); $window->add($vbox);

    my $hb         = Gtk3::Box->new('horizontal', 5);
    my $btn_ref    = Gtk3::Button->new_with_label("Refresh");
    my $btn_gem    = Gtk3::Button->new_with_label("GEM Info");
    my $btn_hil    = Gtk3::Button->new_with_label("Hilbert Map");
    my $btn_sens   = Gtk3::Button->new_with_label("Sensors");
    my $btn_bo_map = Gtk3::Button->new_with_label("BO Map");
    my $chk_gtt    = Gtk3::CheckButton->new_with_label("Show GTT");
    $state->{chk_gtt} = $chk_gtt;
    my $chk_snap   = Gtk3::CheckButton->new_with_label("Snap");
    $state->{chk_snap} = $chk_snap;
    my $chk_auto   = Gtk3::CheckButton->new_with_label("Auto");
    my $upd_entry  = Gtk3::Entry->new; $upd_entry->set_text($state->{update_interval_sec}); $upd_entry->set_width_chars(3);
    my $lbl_method = Gtk3::Label->new("  " . read_method_label());

    # Buddy module status indicator label
    my $lbl_buddy = Gtk3::Label->new(buddy_module_available() ? "  [buddy\u2713]" : "  [buddy\u2717]");

    my $css_provider = Gtk3::CssProvider->new;
    $css_provider->load_from_data(
        ".orange-btn { background: darkorange; color: black; }\n" .
        ".green-btn  { background: #004400;    color: #aaffaa; }\n" .
        ".blue-btn   { background: #003366;    color: #aaccff; }\n" .
        ".buddy-on   { color: #00ff88; font-weight: bold; }\n" .
        ".buddy-off  { color: #888888; }\n"
    );
    my $screen = Gtk3::Gdk::Screen::get_default();
    Gtk3::StyleContext::add_provider_for_screen($screen, $css_provider, Gtk3::STYLE_PROVIDER_PRIORITY_APPLICATION);

    for my $btn ($btn_gem, $btn_hil) { $btn->get_style_context->add_class('orange-btn'); }
    $btn_bo_map->get_style_context->add_class('blue-btn');
    if (-r $AMDGPU_VRAM_PATH) { $btn_ref->get_style_context->add_class('green-btn'); }
    $lbl_buddy->get_style_context->add_class(buddy_module_available() ? 'buddy-on' : 'buddy-off');

    $hb->pack_start($btn_ref,    FALSE, FALSE, 2);
    $hb->pack_start($btn_gem,    FALSE, FALSE, 2);
    $hb->pack_start($btn_hil,    FALSE, FALSE, 2);
    $hb->pack_start($btn_sens,   FALSE, FALSE, 2);
    $hb->pack_start($btn_bo_map, FALSE, FALSE, 2);
    $hb->pack_start($chk_gtt,    FALSE, FALSE, 5);
    $hb->pack_start($chk_snap,   FALSE, FALSE, 5);
    $hb->pack_start($chk_auto,   FALSE, FALSE, 8);
    $hb->pack_start($upd_entry,  FALSE, FALSE, 2);
    $hb->pack_start(Gtk3::Label->new("s"), FALSE, FALSE, 2);
    $hb->pack_end($lbl_buddy,    FALSE, FALSE, 4);
    $hb->pack_end($lbl_method,   FALSE, FALSE, 4);
    $vbox->pack_start($hb, FALSE, FALSE, 2);

    my $da = Gtk3::DrawingArea->new;
    $da->set_size_request(860, 300);
    $da->set_has_tooltip(TRUE);
    $da->set_events(['pointer-motion-mask', 'button-press-mask']);
    $state->{drawing_area} = $da;
    $da->signal_connect('draw'          => \&on_draw_main);
    $da->signal_connect('query-tooltip' => \&on_query_tooltip_main);
    $da->signal_connect('motion-notify-event' => sub {
        my ($w, $e) = @_;
        my $new_idx = undef;
        for my $a (@{$state->{map_areas}}) { if ($e->x>=$a->{x} && $e->x<=$a->{x}+$a->{w} && $e->y>=$a->{y} && $e->y<=$a->{y}+$a->{h}) { $new_idx=$a->{idx}; last; } }
        my $new_gtt = undef;
        if ($state->{gtt_map_areas}) { for my $i (0 .. $#{$state->{gtt_map_areas}}) { my $a = $state->{gtt_map_areas}[$i]; if ($e->x>=$a->{x} && $e->x<=$a->{x}+$a->{w} && $e->y>=$a->{y} && $e->y<=$a->{y}+$a->{h}) { $new_gtt = $i; last; } } }
        my $new_gem = undef;
        if ($state->{gem_strip_visible} && $state->{gem_strip_areas}) { for my $i (0 .. $#{$state->{gem_strip_areas}}) { my $a = $state->{gem_strip_areas}[$i]; if ($e->x>=$a->{x} && $e->x<=$a->{x}+$a->{w} && $e->y>=$a->{y} && $e->y<=$a->{y}+$a->{h}) { $new_gem = $i; last; } } }
        my $changed = (($hover_idx//-1) != ($new_idx//-1)) || (($hover_gtt//-1) != ($new_gtt//-1)) || (($hover_gem//-1) != ($new_gem//-1));
        $hover_idx = $new_idx; $hover_gtt = $new_gtt; $hover_gem = $new_gem;
        $w->queue_draw if $changed; return FALSE;
    });

    $da->signal_connect('button-press-event' => sub {
        my ($w, $e) = @_;
        return FALSE unless $e->button == 1 || $e->button == 3;
        my $hit_area = undef;
        for my $a (@{$state->{map_areas}}) { if ($e->x>=$a->{x} && $e->x<=$a->{x}+$a->{w} && $e->y>=$a->{y} && $e->y<=$a->{y}+$a->{h}) { $hit_area = $a; last; } }
        my $hit_gtt = undef;
        if ($state->{gtt_map_areas}) { for my $a (@{$state->{gtt_map_areas}}) { if ($e->x>=$a->{x} && $e->x<=$a->{x}+$a->{w} && $e->y>=$a->{y} && $e->y<=$a->{y}+$a->{h}) { $hit_gtt = $a; last; } } }
        return FALSE unless defined $hit_area || defined $hit_gtt;
        if ($e->button == 1) {
            if ($hit_gtt) { push_status(sprintf("GTT PID %d (%s) selected. Right-click to read CPU-mapped DRM memory.", $hit_gtt->{pid}, $hit_gtt->{cmd})); return TRUE; }
            my $ratio = ($e->x - $hit_area->{x}) / $hit_area->{w}; $ratio = 0 if $ratio < 0; $ratio = 1 if $ratio > 1;
            my $clicked_page = $hit_area->{data}{start} + int($ratio * $hit_area->{data}{size}); my $clicked_offset = $clicked_page * 4096;
            my $anchor = ($state->{chk_snap} && $state->{chk_snap}->get_active) ? $hit_area->{data}{start} * 4096 : $clicked_offset;
            $state->{range_anchor_offset} = $anchor; $state->{range_start_offset} = $state->{range_end_offset} = undef;
            $w->queue_draw; $state->{hilbert_window_ref}->queue_draw if defined $state->{hilbert_window_ref};
            push_status(sprintf("Selection start set to 0x%x (Region %d)", $anchor, $hit_area->{idx})); return TRUE;
        }
        if ($e->button == 3) {
            if ($hit_gtt) {
                my $pid = $hit_gtt->{pid}; my $cmd = $hit_gtt->{cmd};
                push_status("Reading DRM mappings from /proc/$pid/mem for $cmd...");
                Glib::Idle->add(sub {
                    my $start_time = time(); my $res = read_drm_memory_for_pid($pid); my $elapsed = time() - $start_time;
                    if ($res) {
                        my ($raw_ref, $map_info) = @$res; $state->{read_method} = "/proc/$pid/mem";
                        show_vram_image_popup($window, $raw_ref, { title => "DRM Maps - $cmd", pid => $pid }, $map_info);
                        push_status(sprintf("Loaded %s in %.3fs [%s]", format_bytes(length($$raw_ref)), $elapsed, $state->{read_method}));
                    } else { push_status("Failed to read DRM memory for PID $pid"); }
                    return FALSE;
                });
                $w->queue_draw; return TRUE;
            }
            return FALSE unless defined $hit_area;
            unless (-r $AMDGPU_VRAM_PATH) { push_status("No read method available -- run as root"); return TRUE; }
            my $ratio = ($e->x - $hit_area->{x}) / $hit_area->{w}; $ratio = 0 if $ratio < 0; $ratio = 1 if $ratio > 1;
            my $clicked_page = $hit_area->{data}{start} + int($ratio * $hit_area->{data}{size}); my $clicked_offset = $clicked_page * 4096;
            my ($start_offset, $end_offset);
            if ($state->{chk_snap} && $state->{chk_snap}->get_active) {
                my $anchor_idx = 0;
                if (defined $state->{range_anchor_offset}) {
                    for my $i (0 .. $#{$state->{regions}}) { my $rs = $state->{regions}[$i]{start} * 4096; my $re = ($state->{regions}[$i]{end} + 1) * 4096; if ($state->{range_anchor_offset} >= $rs && $state->{range_anchor_offset} < $re) { $anchor_idx = $i; last; } }
                } else { $anchor_idx = $hit_area->{idx}; }
                my $click_idx = $hit_area->{idx}; ($anchor_idx, $click_idx) = ($click_idx, $anchor_idx) if $anchor_idx > $click_idx;
                $start_offset = $state->{regions}[$anchor_idx]{start} * 4096; $end_offset = ($state->{regions}[$click_idx]{end} + 1) * 4096;
            } elsif (defined $state->{range_anchor_offset}) {
                $start_offset = $state->{range_anchor_offset}; $end_offset = $clicked_offset + 4096;
            } else {
                $start_offset = $hit_area->{data}{start} * 4096; $end_offset = ($hit_area->{data}{end} + 1) * 4096;
            }
            if ($start_offset > $end_offset) { ($start_offset, $end_offset) = ($end_offset, $start_offset); }
            $state->{range_start_offset} = $start_offset; $state->{range_end_offset} = $end_offset; $state->{range_anchor_offset} = undef;
            my $read_size = $end_offset - $start_offset; my $max_read = 256 * 1024 * 1024;
            if ($read_size > $max_read) { $read_size = $max_read; $end_offset = $start_offset + $read_size; push_status("Selection too large, truncating to 256MB"); }
            else { push_status(sprintf("Reading %s at offset 0x%x via debugfs...", format_bytes($read_size), $start_offset)); }
            my ($s_idx, $e_idx) = get_indices_for_offsets($start_offset, $end_offset);
            Glib::Idle->add(sub {
                my $start_time = time(); my $raw_ref = read_vram($start_offset, $read_size); my $elapsed = time() - $start_time;
                if (defined $raw_ref && length($$raw_ref) > 0) {
                    my $map_info = build_multi_region_map_info($s_idx, $e_idx, $start_offset, length($$raw_ref));
                    my $title_str = ($s_idx == $e_idx) ? "Region $s_idx" : "Regions $s_idx - $e_idx";
                    show_vram_image_popup($window, $raw_ref, { title => $title_str, pid => "GPU" }, $map_info);
                    push_status(sprintf("Loaded %s in %.3fs [%s]", format_bytes(length($$raw_ref)), $elapsed, $state->{read_method}));
                } else { push_status("Read failed -- check root permissions"); }
                return FALSE;
            });
            $w->queue_draw; $state->{hilbert_window_ref}->queue_draw if defined $state->{hilbert_window_ref}; return TRUE;
        }
        return FALSE;
    });

    $vbox->pack_start($da, TRUE, TRUE, 0);
    $state->{statusbar}      = Gtk3::Statusbar->new;
    $state->{status_context} = $state->{statusbar}->get_context_id("main");
    $vbox->pack_start($state->{statusbar}, FALSE, FALSE, 0);

    my $do_refresh = sub {
        # Priority: vramgaze_buddy module > amdgpu_vram_mm fallback
        my $n;
        if (buddy_module_available()) {
            $n = parse_buddy_nodes();
            if ($n > 0) {
                $state->{using_buddy_module} = 1;
                debug(sprintf("Using vramgaze_buddy: %d regions", $n));
            } else {
                # Module present but returned nothing -- fall back
                $state->{using_buddy_module} = 0;
                $n = parse_vram_mm($VRAM_FILE);
                debug("vramgaze_buddy returned 0 regions, fell back to amdgpu_vram_mm");
            }
        } else {
            $state->{using_buddy_module} = 0;
            $n = parse_vram_mm($VRAM_FILE);
        }

        # Update buddy label styling live
        if (buddy_module_available()) {
            $lbl_buddy->set_text("  [buddy\x{2713}]");
            $lbl_buddy->get_style_context->remove_class('buddy-off');
            $lbl_buddy->get_style_context->add_class('buddy-on');
        } else {
            $lbl_buddy->set_text("  [buddy\x{2717}]");
            $lbl_buddy->get_style_context->remove_class('buddy-on');
            $lbl_buddy->get_style_context->add_class('buddy-off');
        }

        $state->{gem_data} = parse_gem_info($GEM_FILE);
        my %gtt_by_pid;
        for my $pid (keys %{$state->{gem_data}}) {
            my $g = $state->{gem_data}{$pid}; my $gtt_total = 0;
            for my $bo (@{$g->{bos}}) { $gtt_total += $bo->{bytes} if $bo->{type} eq 'GTT'; }
            $gtt_by_pid{$pid} = { cmd => $g->{cmd}, bytes => $gtt_total } if $gtt_total > 0;
        }
        $state->{gtt_by_pid} = \%gtt_by_pid;
        my $raw_gtt = `cat /sys/class/drm/card$DRI_INDEX/device/gtt_size 2>/dev/null || echo 0`; chomp $raw_gtt;
        $state->{gtt_total} = ($raw_gtt =~ /^\d+$/ && $raw_gtt > 0) ? $raw_gtt : 2 * 1024 * 1024 * 1024;
        my %by_size;
        for my $pid (keys %{$state->{gem_data}}) {
            my $g = $state->{gem_data}{$pid};
            for my $bo (@{$g->{bos}}) {
                next unless uc($bo->{type}) eq 'VRAM';
                push @{$by_size{ $bo->{bytes} }}, { pid=>$pid, cmd=>$g->{cmd}, handle=>$bo->{handle} };
            }
        }
        $state->{gem_map_by_size} = \%by_size;
        push_status($n ? make_status_text() : "No data");
        $lbl_method->set_text("  " . read_method_label());
        $da->queue_draw;
        if (defined $state->{hilbert_window_ref}) {
            $state->{hilbert_state}{backing_surface} = undef if defined $state->{hilbert_state};
            $state->{hilbert_window_ref}->queue_draw;
        }
    };

    $btn_ref->signal_connect(clicked  => $do_refresh);
    $btn_gem->signal_connect(clicked  => sub { show_gem_window($window) });
    $btn_hil->signal_connect(clicked  => sub { $do_refresh->(); show_hilbert_view($window) });
    $btn_sens->signal_connect(clicked => sub { show_sensors_window($window) });
    $btn_bo_map->signal_connect(clicked => sub { $state->{gem_strip_visible} = !$state->{gem_strip_visible}; $da->queue_draw; });
    $chk_gtt->signal_connect(toggled => sub { $da->queue_draw; });
    $chk_auto->signal_connect(toggled => sub {
        if ($chk_auto->get_active) {
            my $secs = $upd_entry->get_text; $secs = $state->{update_interval_sec} unless $secs =~ /^\d+\.?\d*$/ && $secs > 0;
            $state->{update_timer_id} = Glib::Timeout->add(int($secs*1000), sub { return FALSE unless $chk_auto->get_active; $do_refresh->(); return TRUE; });
        } else { if (defined $state->{update_timer_id}) { Glib::Source->remove($state->{update_timer_id}); $state->{update_timer_id} = undef; } }
    });
    $upd_entry->signal_connect(activate => sub { my $v = $upd_entry->get_text; $state->{update_interval_sec} = ($v =~ /^\d+\.?\d*$/ && $v > 0) ? $v : 2; });

    $window->show_all;
    $do_refresh->();
    Gtk3->main;
}

main();
