Mastodon Politics, Power, and Science

Tuesday, March 10, 2026

Handle System Refactor Plan in the Campaign Manager

 J. Rogers, SE Ohio

The Core Principle

CampaignManager owns the mouse and screen. Controllers own the world and tactical maps. The boundary between them is a single question: "what is at this screen position?"

CampaignManager never computes world coordinates. 

Controllers never see pygame events.


What Changes in CampaignManager

Input loop becomes purely screen-space

The event loop stops computing wx, wy entirely. It only works in screen pixels.

MOUSEBUTTONDOWN(sx, sy)

  → handle_id = controller.get_handle_at(sx, sy)
  → if handle_id: begin drag
  → else: map pan begins

MOUSEMOTION(sx, sy)
  → if dragging handle: controller.update_handle(handle_id, sx, sy) → action?
  → if panning: update cam_x/cam_y

MOUSEBUTTONUP(sx, sy)  
  → if dragging handle and drag_dist > threshold:
      controller.end_handle_drag(handle_id, sx, sy) → action?
  → elif dragging handle (short click):
      controller.activate_handle(handle_id, sx, sy) → action?
  → pan ends

MOUSEWHEEL
  → update zoom (pure screen math, no controller involved)

KEYDOWN ESC
  → go_up_level (no controller involved)

KEYDOWN DELETE
  → controller.delete_active_handle() → action? (if a handle is "selected")

The action dict that comes back from any controller call flows into _handle_action exactly as now. Nothing changes there.

State CampaignManager keeps

self.cam_x, self.cam_y, self.zoom     # screen-space camera state
self.dragging_handle_id               # replaces dragging_marker + drag_offset
self.dragging_map                     # bool, for pan
self.drag_start_pos                   # screen pixels, for threshold test
self._overlay_painting                # bool, active paint stroke
```

`dragging_marker`, `drag_offset`, `pending_click_pos` — all deleted. Controllers own that state internally.

### Sidebar and widget handling stays in CampaignManager

Widgets and overlay sidebar are still iterated here before hit-testing the map. The sidebar guard (pos[0] < SIDEBAR_WIDTH) stays exactly where it is. This doesn't change.

### Right-click
```
MOUSEBUTTONDOWN button=3 (sx, sy)
  → handle_id = controller.get_handle_at(sx, sy)
  → controller.context_action(handle_id, sx, sy) → action?

No special-casing of overlay erase in CampaignManager. The controller decides what right-click means based on active tab and what was hit.


What Changes in BaseController

Four concrete methods replace on_map_event. These are the full public interface for all map interaction:

def get_handle_at(self, sx, sy) -> str | None

Returns a handle ID string if something interactive is at screen position (sx, sy), else None. The controller does all coordinate translation internally. Handle IDs are opaque strings to CampaignManager — "marker:42", "vector_point:7:3", "rotation_handle:42", "tile:12:8".

def update_handle(self, handle_id, sx, sy) -> dict | None

Called every MOUSEMOTION while a handle is being dragged. Controller translates (sx, sy) to world coords using its own camera and coord_scale, updates internal state. Returns an action dict if something needs to propagate (e.g. {"action": "update_player_view"} when a view marker moves), else None.

def end_handle_drag(self, handle_id, sx, sy) -> dict | None

Called on MOUSEBUTTONUP after a drag (distance > threshold). Controller saves the final position to DB, clears drag state. Returns action dict or None.

def activate_handle(self, handle_id, sx, sy) -> dict | None

Called on MOUSEBUTTONUP after a short click (distance <= threshold). Controller interprets based on handle type — toggle door, enter portal, toggle view marker, etc. Returns action dict or None.

def context_action(self, handle_id, sx, sy) -> dict | None

Called on right-click. Controller shows context menu, triggers erase, etc. Returns action dict or None.

def delete_active_handle(self) -> dict | None

Called on DELETE key. Controller deletes the currently selected handle if there is one (e.g. selected vector point). Returns action dict or None.

What BaseController no longer needs

handle_shared_input — deleted entirely. The shared input never reaches controllers now.

The overlay paint methods (_do_overlay_paint, _do_overlay_erase) stay in BaseController because they contain real logic, but they are called internally from the controller's own methods, not from CampaignManager.


What Changes in GeoController

Handles it exposes

Handle ID patternWhat it isgetupdateend_dragactivatecontext
marker:<id>Any POI markerhit radius 10pxmove marker in memorysave to DBenter_marker or toggle viewedit/delete menu
vector_point:<vec_id>:<idx>Vector control point (TOOLS tab only)hit radius 10pxmove point in memory— (points auto-save with vector)select point
map_emptyNothing hitshift+click → create markerright-click → nothing

get_handle_at implementation 

def get_handle_at(self, sx, sy):
    # check markers
    for m in self.markers:
        props = m.get('properties', {})
        mx, my = self._world_to_screen(props['world_x'], props['world_y'])
        if math.hypot(sx - mx, sy - my) < 10:
            return f"marker:{m['id']}"
    
    # check vector points if active_vector and TOOLS tab
    if self.active_vector and self.active_tab == "TOOLS":
        for i, pt in enumerate(self.active_vector['properties']['points']):
            px, py = self._world_to_screen(pt[0], pt[1])
            if math.hypot(sx - px, sy - py) < 10:
                return f"vector_point:{self.active_vector.get('id','new')}:{i}"
    
    return None  # nothing hit → CampaignManager will pan

Internal _world_to_screen method (new, private)

GeoController needs to own its own screen→world conversion. It already knows cam_x, cam_y, zoom because CampaignManager updates these on the controller whenever the camera changes. A single method:

def _world_to_screen(self, wx, wy):
    cx = SCREEN_WIDTH // 2
    cy = SCREEN_HEIGHT // 2
    return cx + (wx - self.cam_x) * self.zoom, cy + (wy - self.cam_y) * self.zoom

def _screen_to_world(self, sx, sy):
    cx = SCREEN_WIDTH // 2
    cy = SCREEN_HEIGHT // 2
    return (sx - cx) / self.zoom + self.cam_x, (sy - cy) / self.zoom + self.cam_y

Camera sync

CampaignManager calls a single method when camera changes:

controller.set_camera(cam_x, cam_y, zoom)

Controller stores these and uses them in all its coordinate conversions. This replaces passing cam_x/cam_y/zoom as parameters to every method.


What Changes in TacticalController

Handles it exposes

Handle ID patternWhat it isgetupdateend_dragactivatecontext
marker:<id>Any POI markerhit radius 15pxmove in memorysave to DBdoor/trap/light/stairs/view actionsedit/delete menu
rotation_handle:<id>View marker facing handlehit radius 8pxupdate facing_degreessave to DB
tile:<c>:<r>Grid tile (TOOLS tab only)hit by cell mathpaint while draggingright-click → create marker

get_handle_at implementation

def get_handle_at(self, sx, sy):
    # rotation handle takes priority
    view_marker = next((m for m in self.markers 
                        if m['properties'].get('is_view_marker')), None)
    if view_marker:
        props = view_marker['properties']
        vsx, vsy = self._world_to_screen(props['world_x'], props['world_y'])
        facing = math.radians(props.get('facing_degrees', 0))
        hx = vsx + math.cos(facing) * 25
        hy = vsy + math.sin(facing) * 25
        if math.hypot(sx - hx, sy - hy) < 8:
            return f"rotation_handle:{view_marker['id']}"
    
    # markers
    for m in self.markers:
        props = m.get('properties', {})
        mx, my = self._world_to_screen(props['world_x'], props['world_y'])
        if math.hypot(sx - mx, sy - my) < 15:
            return f"marker:{m['id']}"
    
    # tile (TOOLS tab)
    if self.active_tab == "TOOLS":
        c, r = self._screen_to_cell(sx, sy)
        if 0 <= c < self.grid_width and 0 <= r < self.grid_height:
            return f"tile:{c}:{r}"
    
    return None

Camera Sync Protocol

This is the key that makes controllers self-sufficient for coordinate conversion.

CampaignManager calls controller.set_camera(cam_x, cam_y, zoom):

  • On every successful pan (MOUSEMOTION while dragging map)
  • On every zoom (MOUSEWHEEL)
  • On _set_controller() when a new node loads

Controllers store self.cam_x, self.cam_y, self.zoom and use them in _world_to_screen and _screen_to_world. They never receive these as parameters to any public method.


The Scriptable Interface This Gives You

Because all map interaction now goes through four clean methods, you can:

Record and replay — log every activate_handle, end_handle_drag call with its handle ID and screen position. Replay it later.

Scripted actions — call controller.activate_handle("marker:42", 0, 0) directly to fire the same logic as a player click, without needing to synthesize a mouse event.

Remappable input — the mapping of "middle click → query_point", "shift+left click → create marker", "right click → context menu" lives entirely in CampaignManager's event loop. Change it in one place. Controllers don't know what button was pressed.

Multiple input devices — gamepad, touch, keyboard navigation — all just call the same four controller methods with screen coordinates. CampaignManager translates device input → handle calls.

Testing — test controller logic by calling activate_handle("marker:42", 0, 0) directly. No pygame event synthesis needed.


Summary of What Gets Deleted

Deleted fromWhat
CampaignManager_hit_test_marker, drag_offset, dragging_marker, pending_click_pos, all wx/wy computation, coord_scale usage
BaseControllerhandle_shared_input entirely
Both controllerson_map_event, all pygame.MOUSEBUTTONDOWN/UP/MOTION pattern matching, all cam_x/cam_y/zoom parameters on public methods

Summary of What Gets Added

Added toWhat
CampaignManagerdragging_handle_id, calls to 5 controller methods
BaseControllerAbstract: get_handle_at, update_handle, end_handle_drag, activate_handle, context_action, delete_active_handle, set_camera
Both controllers_world_to_screen, _screen_to_world, _screen_to_cell (tac only), set_camera, implementation of the 6 abstract methods

Sequencing

  1. Add set_camera + coordinate helpers to both controllers, wire up set_camera calls in CampaignManager. No behaviour change yet.
  2. Add the 6 abstract methods to BaseController as stubs returning None.
  3. Implement get_handle_at in both controllers. Wire _hit_test_marker in CampaignManager to call it. Verify nothing breaks.
  4. Implement update_handle + end_handle_drag in both. Remove dragging_marker/drag_offset from CampaignManager.
  5. Implement activate_handle in both. Remove short-click logic from CampaignManager.
  6. Implement context_action + delete_active_handle. Remove remaining controller-specific branches from CampaignManager.
  7. Delete handle_shared_input from BaseController, on_map_event from both controllers.

Each step is independently testable. The game runs correctly after every step.

Monday, March 9, 2026

Made Progress on the r36s handheld.

 I got it last year and loaded a game on it and couldn't get it to not pop up the menu every few seconds because that menu had been mapped to clicking the left joystick. Super annoying. 

So I forgot about it, busy doing other things.  When I went to try it again, black screen of death, the cheap factory sd card had bit rotted. I installed arkos on it, I have screen three btw.  That was a challenge.  

Then I figured out how to get a working game without the annoying menu popup by mapping that menu onto another button.

I am still fighting the controls. I have not figured out now to map the controls for one game system without it screwing up a differrnt game.  Will have to write that up if I can figure out how it works. 

But I found a script to fix the movie playing on this version of arkos. 

https://www.youtube.com/watch?v=Deys0VsF0RA

and I figured out how to transcode videos to fit the screen size this saves a lot of space if you care abut that.

To transcode for r-36S

ffmpeg -i input_video.mp4 -vf "scale=640:-2" -c:v libx264 -profile:v baseline -level 3.0 -pix_fmt yuv420p -crf 23 -c:a aac -b:a 128k output.mp4

for i in *.mkv; do ffmpeg -i "$i" -vf "scale=640:-2" -c:v libx264 -profile:v baseline -level 3.0 -pix_fmt yuv420p -crf 23 -c:a aac "${i%.*}.mp4"; done

Image my surprise when I tried to auto-install ports and I learned that the port master in the tools menu is just a stub to download the real thing.  But I don't have wifi yet.  So I figured out how to install portmaster in one go locally. 

https://www.reddit.com/r/R36S/comments/1qvywsh/install_portmaster/#:~:text=Replace%20the%20stock%20SD%20Card,(Recommended%20for%20offline%20devices)

After that I loaded ko reader like I had on the kindle, but the controls can be mapped better there too.  Still working on making that work correctly.  It does things like press f2 to end screen saver, but f2 is not mapped to any buttons.  And just going into some directories makes the screen go black and I have to exit the book reader at that point, it could be missing a library for a cerrtain book type. 

I loaded rock box, and it is actually pretty good there. No notes there, but I used rock box decades ago on a sansa player.

I also loaded a audiobook reader and a game platform.  I tried to add more content to the audio book and it did not worrk, just locks up there, can't navigate to most of them to try it, might just see if rock box can play the books like it does music.

This is turning out to be a really great little device for going on long trips. Load it up with a few movies, some songs, a few books and audio books and it can do well enough to keep you distracted for a few hours. Might need a charge only cable hooked to a power bank or seat charger to keep it working llongerr than 5 hours.  Or we might be able to put a 3d printed cover over the battery and put a double sized battery in it.

Next up for this is to find a cheap wifi dongle that I can just plug into the usb c otg connector. And put in a 256GB sd card in the second slot for lots of media. Let them eat bread and circuses!!! 

Cracked my kindle paper white 2

 I cracked my kindle paper white 2, got root on it and am running the software of my choice. Weird that I had to break into my own hardware. That in theory is my property.

I did a very controlled upgrade to a firmware version that was susceptible to the Winterbreak. I had managed to not update it for over 5 years.

I followed this guide after trying a few other guides that did not work.

https://www.youtube.com/watch?v=IRW_EYDcW1o

After it updated and was working I put a directory in place of the file name that kindle uses to download firmware, this works for me because I have the firmware version just before they fixed that workaround. 

One thing I did have to do different is that for the life of me I could not get the hotfix to load, but I was able to just add Kual 2.0.azw2 to my document folder and run it from there. 

But I loaded ko reader and a few other things and look around inside the kindle for the first time ever for me.

And for fun I ssh'ed into the kindle over wifi and wrote a banner program to print to the display. 






Wrote a script for printing the banners to the screen.  You have to save it in '/mnt/us/'  to save it between the cleaning of the room folder that happens every so often. 

[root@kindle us]# cat print_banner.sh 

#!/bin/sh

# banner.sh - print each word as 5-row ASCII art, stacked vertically

# Usage: ./banner.sh Hello from root


# ----- letter definitions (5 rows each) -----

H1="*   *"; H2="*   *"; H3="*****"; H4="*   *"; H5="*   *"

E1="*****"; E2="*    "; E3="***  "; E4="*    "; E5="*****"

L1="*    "; L2="*    "; L3="*    "; L4="*    "; L5="*****"

O1=" *** "; O2="*   *"; O3="*   *"; O4="*   *"; O5=" *** "

W1="*   *"; W2="*   *"; W3="* * *"; W4="** **"; W5="*   *"

R1="**** "; R2="*   *"; R3="**** "; R4="*  * "; R5="*   *"

D1="**** "; D2="*   *"; D3="*   *"; D4="*   *"; D5="**** "

F1="*****"; F2="*    "; F3="***  "; F4="*    "; F5="*    "

M1="*   *"; M2="** **"; M3="* * *"; M4="*   *"; M5="*   *"

Y1="*   *"; Y2=" * * "; Y3="  *  "; Y4="  *  "; Y5="  *  "

T1="*****"; T2="  *  "; T3="  *  "; T4="  *  "; T5="  *  "

U1="*   *"; U2="*   *"; U3="*   *"; U4="*   *"; U5=" *** "

S1=" ****"; S2="*    "; S3=" *** "; S4="    *"; S5="**** "

P1="**** "; P2="*   *"; P3="**** "; P4="*    "; P5="*    "

G1=" ****"; G2="*    "; G3="*  **"; G4="*   *"; G5=" ****"

A1="  *  "; A2=" * * "; A3="*****"; A4="*   *"; A5="*   *"

B1="**** "; B2="*   *"; B3="**** "; B4="*   *"; B5="**** "

C1=" ****"; C2="*    "; C3="*    "; C4="*    "; C5=" ****"

D1="**** "; D2="*   *"; D3="*   *"; D4="*   *"; D5="**** "

I1="*****"; I2="  *  "; I3="  *  "; I4="  *  "; I5="*****"

J1="  ***"; J2="   * "; J3="   * "; J4="*  * "; J5=" **  "

K1="*   *"; K2="*  * "; K3="***  "; K4="*  * "; K5="*   *"

N1="*   *"; N2="**  *"; N3="* * *"; N4="*  **"; N5="*   *"

Q1=" *** "; Q2="*   *"; Q3="*   *"; Q4="* * *"; Q5=" *** "

V1="*   *"; V2="*   *"; V3=" * * "; V4=" * * "; V5="  *  "

X1="*   *"; X2=" * * "; X3="  *  "; X4=" * * "; X5="*   *"

Z1="*****"; Z2="   * "; Z3="  *  "; Z4=" *   "; Z5="*****"

SP1="     "; SP2="     "; SP3="     "; SP4="     "; SP5="     "


# ----- clear screen -----

eips -c


# ----- collect words -----

if [ $# -eq 1 ]; then

    # split single argument on spaces

    set -- $(echo "$1" | tr ' ' '\n')

fi


# ----- starting Y position -----

y=5


# ----- loop over each word -----

for word in "$@"; do

    # convert to uppercase

    w=$(echo "$word" | tr '[:lower:]' '[:upper:]')

    len=${#w}

    

    # initialize rows for this word

    r1=""; r2=""; r3=""; r4=""; r5=""

    

    i=0

    while [ $i -lt $len ]; do

        char=$(echo "$w" | cut -c $((i+1)))

        case $char in

            A) r1="$r1$A1"; r2="$r2$A2"; r3="$r3$A3"; r4="$r4$A4"; r5="$r5$A5" ;;

            B) r1="$r1$B1"; r2="$r2$B2"; r3="$r3$B3"; r4="$r4$B4"; r5="$r5$B5" ;;

            C) r1="$r1$C1"; r2="$r2$C2"; r3="$r3$C3"; r4="$r4$C4"; r5="$r5$C5" ;;

            D) r1="$r1$D1"; r2="$r2$D2"; r3="$r3$D3"; r4="$r4$D4"; r5="$r5$D5" ;;

            E) r1="$r1$E1"; r2="$r2$E2"; r3="$r3$E3"; r4="$r4$E4"; r5="$r5$E5" ;;

            F) r1="$r1$F1"; r2="$r2$F2"; r3="$r3$F3"; r4="$r4$F4"; r5="$r5$F5" ;;

            G) r1="$r1$G1"; r2="$r2$G2"; r3="$r3$G3"; r4="$r4$G4"; r5="$r5$G5" ;;

            H) r1="$r1$H1"; r2="$r2$H2"; r3="$r3$H3"; r4="$r4$H4"; r5="$r5$H5" ;;

            I) r1="$r1$I1"; r2="$r2$I2"; r3="$r3$I3"; r4="$r4$I4"; r5="$r5$I5" ;;

            J) r1="$r1$J1"; r2="$r2$J2"; r3="$r3$J3"; r4="$r4$J4"; r5="$r5$J5" ;;

            K) r1="$r1$K1"; r2="$r2$K2"; r3="$r3$K3"; r4="$r4$K4"; r5="$r5$K5" ;;

            L) r1="$r1$L1"; r2="$r2$L2"; r3="$r3$L3"; r4="$r4$L4"; r5="$r5$L5" ;;

            M) r1="$r1$M1"; r2="$r2$M2"; r3="$r3$M3"; r4="$r4$M4"; r5="$r5$M5" ;;

            N) r1="$r1$N1"; r2="$r2$N2"; r3="$r3$N3"; r4="$r4$N4"; r5="$r5$N5" ;;

            O) r1="$r1$O1"; r2="$r2$O2"; r3="$r3$O3"; r4="$r4$O4"; r5="$r5$O5" ;;

            P) r1="$r1$P1"; r2="$r2$P2"; r3="$r3$P3"; r4="$r4$P4"; r5="$r5$P5" ;;

            Q) r1="$r1$Q1"; r2="$r2$Q2"; r3="$r3$Q3"; r4="$r4$Q4"; r5="$r5$Q5" ;;

            R) r1="$r1$R1"; r2="$r2$R2"; r3="$r3$R3"; r4="$r4$R4"; r5="$r5$R5" ;;

            S) r1="$r1$S1"; r2="$r2$S2"; r3="$r3$S3"; r4="$r4$S4"; r5="$r5$S5" ;;

            T) r1="$r1$T1"; r2="$r2$T2"; r3="$r3$T3"; r4="$r4$T4"; r5="$r5$T5" ;;

            U) r1="$r1$U1"; r2="$r2$U2"; r3="$r3$U3"; r4="$r4$U4"; r5="$r5$U5" ;;

            V) r1="$r1$V1"; r2="$r2$V2"; r3="$r3$V3"; r4="$r4$V4"; r5="$r5$V5" ;;

            W) r1="$r1$W1"; r2="$r2$W2"; r3="$r3$W3"; r4="$r4$W4"; r5="$r5$W5" ;;

            X) r1="$r1$X1"; r2="$r2$X2"; r3="$r3$X3"; r4="$r4$X4"; r5="$r5$X5" ;;

            Y) r1="$r1$Y1"; r2="$r2$Y2"; r3="$r3$Y3"; r4="$r4$Y4"; r5="$r5$Y5" ;;

            Z) r1="$r1$Z1"; r2="$r2$Z2"; r3="$r3$Z3"; r4="$r4$Z4"; r5="$r5$Z5" ;;

            *) r1="$r1$SP1"; r2="$r2$SP2"; r3="$r3$SP3"; r4="$r4$SP4"; r5="$r5$SP5" ;;

        esac

        i=$((i+1))

    done

    

    # print the 5 rows for this word

    eips 5 $y "$r1"

    eips 5 $((y+1)) "$r2"

    eips 5 $((y+2)) "$r3"

    eips 5 $((y+3)) "$r4"

    eips 5 $((y+4)) "$r5"

    

    # move y down by 5 for next word (no gap)

    y=$((y+6))

done


Handle System Refactor Plan in the Campaign Manager

 J. Rogers, SE Ohio The Core Principle CampaignManager owns the mouse and screen. Controllers own the world and tactical maps. The boundar...