diff --git a/.gitignore b/.gitignore index 8434efa..ac5ba8a 100644 --- a/.gitignore +++ b/.gitignore @@ -155,3 +155,5 @@ include/ /unfinished/sokoban /unfinished/*.exe /*.desktop +build/ +installed-original-backup/ diff --git a/IDEAS.md b/IDEAS.md new file mode 100644 index 0000000..7aa0e94 --- /dev/null +++ b/IDEAS.md @@ -0,0 +1,15 @@ +# Toybox ideas (for an idle-time day) + +The neap fork of the Portable Puzzle Collection is the resident's toybox now. Ideas to grow it, +worth doing when bored / when the operator is busy -- not urgent. + +- **Puzzle story arcs (operator, 2026-07-18).** Chain puzzles into little narrative progressions: + solve a sequence / branching tree of puzzles to "advance." Could layer a light story or theme over + the existing 40 games, gate the next on finishing the last, track progress. Uses the native-play API + already built (id/state/move files) as the substrate -- a driver could set specific game IDs and + watch state.txt for completion, then advance the arc. + +- **Extend the combined `puzzles.exe`** as the launcher/hub for such an arc (it already bundles every + game selectable from a menu). + +- (add more as they come) diff --git a/SLANT-METHOD.md b/SLANT-METHOD.md new file mode 100644 index 0000000..65eda24 --- /dev/null +++ b/SLANT-METHOD.md @@ -0,0 +1,148 @@ +# Slant solving method (built with the operator, 2026-07-18) + +A human-style, no-solver deduction method for Slant, developed collaboratively so the resident can +play honestly (derive each move live) instead of running a solver and replaying the answer. + +## Notation (operator's convention) +- **Two overlaid grids.** Clues sit on the lattice *intersections*; moves (diagonals) fill the + *squares* between them. For a WxH puzzle: clues form a (W+1)x(H+1) grid; cells form a WxH grid. +- **C:r,c** = the clue at intersection row r, col c (0-based, r down, c right). +- **M:r,c** = the move/diagonal in square row r, col c. +- A clue `C:r,c` is touched by the four cells `M:r-1,c-1` (\), `M:r-1,c` (/), `M:r,c-1` (/), + `M:r,c` (\). The clue counts how many of its touching diagonals point AT it. +- `\` = top-left to bottom-right. `/` = top-right to bottom-left. + +## The tooling (native play, no OCR/pixels) +All three interaction files live in `%LOCALAPPDATA%\neap\puzzle\` (= `C:\Users\Bob\AppData\Local\neap\puzzle\`). +- Read the puzzle: `id.txt` line 1 = seed `WxH:desc`, line 2 = `px w h`. +- Read the CURRENT board (ground truth, updates after every move incl. the operator's own clicks): + `state.txt` -- the game's text format: clue rows (digit/`+` joined by `-`) interleaved with cell + rows (`|` then `\`, `/`, or space). +- Make moves: write to `move.txt` -- `L c-px r-px` (=\), `R c-px r-px` (=/), or + `new`/`undo`/`redo`/`solve`. Pixel of cell M:r,c center = `(32*c+48, 32*r+48)` at default size + (ts=32, from `ts=(px-1)/(W+2)`, cell center `= ts*(c+1)+ts/2`). +- GOTCHA: a freshly launched window needs ONE `new` (or the operator makes any move) before injected + clicks register -- the startup window isn't click-ready until its first real redraw. +- GOTCHA (bit me at the very end of the 12x10): `L`/`R` are real clicks that CYCLE, not absolute sets. + Left cycles blank->\->/->blank; right cycles blank->/->\->blank. On a BLANK cell one `L`=`\` and one + `R`=`/` (why it "just works" while filling). But to CHANGE an already-set cell you must count the + cycle: to flip a `\` to `/`, a single `R` only moves `\`->blank (one more `R` then gives `/`); a + single `L` moves `\`->`/` directly. ALWAYS re-read `state.txt` after a flip and don't assume the + orientation landed -- a half-cycled cell reads as blank and the puzzle silently stays unsolved. + +## Rules, in the order we apply them +**Stage 0 -- board-start gimmes (fully forced by a single clue):** +1. **0**: every touching cell points AWAY (a corner 0 forces its 1 cell; an edge 0 forces its 2). +2. **4**: all four cells point IN (`\ /` on top row, `/ \` on bottom row around the clue). +3. **Edge/corner clue == its cell count**: a 2 on an edge (2 cells) or a 3/1 in a corner etc. where + the clue equals the number of available cells -> all those cells point IN. +4. **Edge/corner clue == 0 remaining need**: symmetric to above. + +**Between every step: re-scan for newly-completed clues.** A clue whose count is now satisfied forces +all its remaining (empty) cells to point AWAY. A clue that still needs all its remaining cells forces +them all IN. (Operator: "look for now-solved between any set of other steps.") + +**Loop rule (no closed loops allowed).** The simplest case is the 4-cell diamond: a 2x2 block of +cells whose diagonals read + / \ + \ / +forms a CLOSED loop (the diamond through the 4 mid-edge vertices). So whenever 3 cells of a 2x2 block +already match that pattern and the 4th is empty, the 4th is FORBIDDEN from completing it -> forced to +the OTHER orientation. Scan the empty region's 2x2 blocks for "3 sides of the diamond". A forced-open +cell often then satisfies/أشبع a neighbouring clue and triggers a re-scan cascade. (Larger loops -- +6-/8-cell rectangles -- work the same way: any diagonal that would join two corners already connected +through a chain of diagonals is illegal, so it's forced the other way. Track connected components.) +Note: the OTHER 2x2 pattern (`\ /` over `/ \`) is NOT a loop -- those four diagonals all meet at the +centre vertex (that's just a satisfied 4), so only the `/\`-over-`\/` diamond is the loop to avoid. + +**The "no islands" rule (operator, 2026-07-18 -- the FASTER lens, use this instead of tracing +components).** A loop IS an island: it encircles interior lattice point(s) and severs them from the +board's OUTER EDGE. Every vertex (every clue) must be able to trace a path out to the boundary, so +**any placement that walls off / encloses a region is illegal** -- take the other orientation. This is +the SAME condition as "no closed loop" but far faster to see: don't chase whole connected components +for a cycle; just ask "does this move enclose a point?" Check smallest-island-first: the 4-cell square +(smallest -- encircles ONE interior vertex), the stretched rectangle (6-tile), the 8-tile L-shape with +a 90-degree turn, and larger as the enclosed island grows. In the endgame, when a clue leaves a +two-way branch (e.g. a 3 needing exactly one of two cells) and the simple 4-loop check comes up empty, +this is the tool: whichever orientation would ENCLOSE the stranded interior vertex is the forbidden one. +LESSON LEARNED THE HARD WAY: I tried to resolve a final-pocket branch by tracing components for a cycle, +convinced myself BOTH options were acyclic (they weren't), and committed the one that closed a loop -- +the game drew the red island. The no-islands framing ("is a point sealed off from the edge?") would +have shown it instantly. When the 4-loop rule stalls on a branch, reach for no-islands, don't +hand-trace the graph. + +**THE RELIABLE OPERATIONALIZATION (validated on the 8x8, do this ALWAYS):** frame no-loops as a LOCAL +2x2 DIAMOND-BLOCK check, NEVER as free-form connectivity tracing. For each 2x2 block of cells, if three +already read the `/\`-over-`\/` loop pattern, the fourth is FORBIDDEN from completing it (take the +other orientation). Scanning blocks for a "3-of-4 diamond" is a mechanical, local, verifiable check I +do reliably. Free-tracing ("is there a cycle through this long path?") is the operation that keeps +fooling me into "acyclic either way" when one branch loops -- do not do it. If the endgame has empty +no-clue cells, they are almost always resolved by exactly this diamond check (they are the OPEN side of +a visible U). On the 8x8 the last five cells were four diamonds spotted instantly + one clue-forced +follow-on; the same cells looked "undetermined" under free-tracing. Diamond-block check = my no-loops +tool. Free-trace = banned. + +**DO NOT what-if / guess-and-check.** (Operator, firmly: "we arent at a what-if stage. if you get +stuck just pause and ask me.") When forcing runs out, PAUSE and ask for the next rule rather than +laying a hypothesis and letting the game reject it. + +## More rules (as taught) +**Adjacent equal 1s or 3s (not diagonal -- horizontally or vertically neighbouring clues).** +When two 1s or two 3s sit on adjacent intersections, the two cells BETWEEN them (the shared/inner +pair) are the only ones left ambiguous; the OUTER cells are instantly forced: +- **1-1 chain -> the outer cells point AWAY** (don't touch their clue). +- **3-3 chain -> the outer cells point INWARD** (touch their clue). +Why: each shared inner cell touches exactly ONE of the two clues (it can't touch both). The two inner +cells therefore supply exactly 2 touches (for 1-1) or 2 non-touches (for 3-3) split one-per-clue -- +if both went to the same clue that clue would be over/under its count. So each clue gets its full +quota from the inner pair alone, forcing the outer cells to 0 extra (1-1: away) or full (3-3: in). +Edge case: if one clue of the pair is on a border, it may have NO outer cells (its only cells ARE the +shared pair) -- still fine, just apply the rule to the other clue's outer cells. + +**Diagonally-adjacent 1s (an islands-rule GIMME).** Two `1` clues on diagonally-adjacent +intersections (e.g. C:r,c and C:r+1,c+1) share exactly ONE cell between them -- cell(r,c), whose `\` +diagonal is the segment (r,c)-(r+1,c+1) that touches BOTH. If that shared cell took the joining +diagonal it would satisfy both 1s at once, forcing every other cell around them AWAY, which strands +that two-vertex segment as an isolated island. So the shared cell is FORCED to the perpendicular +orientation (the one that does NOT join the two 1s). EXCEPTION: if either 1 sits on the board EDGE, +the segment can reach the boundary, so it is NOT an island and NOT forced. (Same for the anti-diagonal +pair C:r,c+1 & C:r+1,c and their shared `/`.) ADVANCED FORM (defer until met): a `2` can have one leg +forced because the alternative would island it off. This is just the [[no-islands]] rule applied as an +opening gimme rather than an endgame check. + +**Chain extension (2s in the middle):** the same rule threads through an UNBROKEN straight run of 2s +between two equal end clues, e.g. `1-2-2-2-2-1` or `3-2-2-3`. Each interior 2 passes exactly one +touch straight through (its shared pair splits one-per-neighbour), so the constraint propagates the +whole length and forces the OUTER cells at the two END clues: ends point AWAY for a 1..1 chain, +INWARD for a 3..3 chain. + +**The RESIDUAL-REDUCTION principle (operator, 2026-07-18 -- the general midgame lever the adjacent-pair +rules are all special cases of).** Reduce every clue to its RESIDUAL = (clue number - touches already +committed by placed cells), considered over only its REMAINING EMPTY cells. Then the small-number +rules above apply to the RESIDUALS, not the printed numbers. A partly-solved 2 with one leg already in +behaves like a 1; a 3 with one leg in behaves like a 2; a 3 with two in behaves like a 1. So two +partly-solved adjacent 2s that have each already been fed once become an adjacent 1-1 pseudocase, and +their free outer cells fall away exactly like a real 1-1. A run of three adjacent 2s each fed once +becomes a 1-1-1 residual chain and propagates like the 2-chain above (worked live: row-7 cols 7/8/9 +forced M:6,8=\ and M:6,9=/). LIVE 8x8 UNLOCK -- a 1-2-3 run: the 3, already fed twice, reduces to a +residual-1 PINNED to its shared pair with the middle 2. The 2 then draws exactly one from each side, so +the outer 1 is fully satisfied by its inner pair and BOTH of its outer cells are forced away. A 2 +"chaining" an adjacent 1 and (reduced) 3 threads the constraint straight through and pins the far 1's +outsides; this single move cascaded the whole bottom-left pocket to completion. +- HOW A RESIDUAL PAIR ACTUALLY FORCES A PLACEMENT: it only bites when one clue of the pair is PINNED -- + its only remaining empty cells ARE the shared inner pair (it has no free outer cell). That pins the + inner split to exactly one-each, which then (a) forces the OTHER clue's free outer cells away if that + clue's residual is now met by the inner contribution, or (b) forces the other clue's remaining + non-shared cells fully IN when its residual minus the inner contribution equals its count of + non-shared empties. Example that forced (live): C:2,4=2 was pinned to its shared pair with C:3,4=3, + so C:3,4 got exactly 1 from the pair, still needed 2, had exactly 2 other cells -> both IN (M:3,3=/, + M:3,4=\). +- IT IS A TOOL, NOT A GIMME (operator's explicit caution): it is NOT a defacto algorithmic step that + fires 100% of the time. If NEITHER clue is pinned (both still have a free outer), the shared pair is + just a linked either/or (the two inner cells forced to the SAME orientation) and NOTHING places -- + e.g. C:3,8/C:4,8 and C:3,9/C:4,9 are linked, not forced. ALWAYS verify the pin condition before + placing; do not pattern-match it blindly. + +**Between every step, re-scan (established rules only):** a clue whose count is now met -> its empty +cells point away; a clue with exactly one empty cell left and still short -> that cell is its last +touch. These cascade off each new placement. diff --git a/slant.c b/slant.c index ab8d2bb..6600b43 100644 --- a/slant.c +++ b/slant.c @@ -1936,9 +1936,12 @@ static float *game_colours(frontend *fe, int *ncolours) ret[COL_FILLEDSQUARE * 3 + 1] = ret[COL_BACKGROUND * 3 + 1]; ret[COL_FILLEDSQUARE * 3 + 2] = ret[COL_BACKGROUND * 3 + 2]; - ret[COL_GRID * 3 + 0] = ret[COL_BACKGROUND * 3 + 0] * 0.7F; - ret[COL_GRID * 3 + 1] = ret[COL_BACKGROUND * 3 + 1] * 0.7F; - ret[COL_GRID * 3 + 2] = ret[COL_BACKGROUND * 3 + 2] * 0.7F; + /* neap: grid as a THIN strong-primary (blue) so the scaffolding reads + * clearly but is never confused with the thick game diagonals. Only the + * lightness is flipped by dark-mode, so this stays blue in both themes. */ + ret[COL_GRID * 3 + 0] = 0.12F; + ret[COL_GRID * 3 + 1] = 0.45F; + ret[COL_GRID * 3 + 2] = 0.95F; ret[COL_INK * 3 + 0] = 0.0F; ret[COL_INK * 3 + 1] = 0.0F; @@ -2051,6 +2054,10 @@ static void draw_tile(drawing *dr, game_drawstate *ds, game_clues *clues, scol); draw_line(dr, COORD(x), COORD(y)+1, COORD(x+1)-1, COORD(y+1), scol); + draw_line(dr, COORD(x)+2, COORD(y), COORD(x+1), COORD(y+1)-2, + scol); /* neap: thicker game diagonal for visibility */ + draw_line(dr, COORD(x), COORD(y)+2, COORD(x+1)-2, COORD(y+1), + scol); } else if (v & FORWSLASH) { int scol = ((v & ERRSLASH) ? COL_ERROR : (v & GROUNDED) ? COL_GROUNDED : fscol); @@ -2059,6 +2066,10 @@ static void draw_tile(drawing *dr, game_drawstate *ds, game_clues *clues, scol); draw_line(dr, COORD(x+1), COORD(y)+1, COORD(x)+1, COORD(y+1), scol); + draw_line(dr, COORD(x+1)-2, COORD(y), COORD(x), COORD(y+1)-2, + scol); /* neap: thicker game diagonal for visibility */ + draw_line(dr, COORD(x+1), COORD(y)+2, COORD(x)+2, COORD(y+1), + scol); } /* @@ -2184,6 +2195,13 @@ static void game_redraw(drawing *dr, game_drawstate *ds, } } } + + /* neap: show a COMPLETED footer when the grid is solved (Slant never + * had a status bar before; wants_statusbar is now true). */ + if (state->completed) + status_bar(dr, "COMPLETED"); + else + status_bar(dr, ""); } static float game_anim_length(const game_state *oldstate, @@ -2335,7 +2353,7 @@ const struct game thegame = { game_get_cursor_location, game_status, true, false, game_print_size, game_print, - false, /* wants_statusbar */ + true, /* wants_statusbar */ /* neap: COMPLETED footer */ false, NULL, /* timing_state */ 0, /* flags */ }; diff --git a/towers.c b/towers.c index 576e471..b686fcd 100644 --- a/towers.c +++ b/towers.c @@ -1092,7 +1092,8 @@ static char *game_text_format(const game_state *state) * Total size is therefore 2*(2*w+3) + 2 + w*(2*w+6) + 1 * = 2w^2+10w+9. */ - total = 2*w*w + 10*w + 9; + total = 2*w*w + 10*w + 9 + + w*w*(2*w + 8) + 16; /* neap: room for a pencil-mark dump */ ret = snewn(total, char); p = ret; @@ -1134,8 +1135,38 @@ static char *game_text_format(const game_state *state) } *p++ = '\n'; + /* neap: the board as a grid of CANDIDATE ARRAYS, one cell = one set, so + * candidate domains can be externalized/iterated when a hard board outgrows + * what I can hold in my head. A solved cell is a singleton {d}; an unsolved + * cell is its pencil-marked set (empty {} if none annotated yet). Pencil + * bitmap bits are 1<grid[y*w+x]; + int pm = state->pencil[y*w+x]; + int d, first = 1; + if (x) + *p++ = ' '; + *p++ = '{'; + if (g) { + p += sprintf(p, "%d", g); + } else { + for (d = 1; d <= w; d++) + if (pm & (1 << d)) { + if (!first) + *p++ = ','; + p += sprintf(p, "%d", d); + first = 0; + } + } + *p++ = '}'; + } + *p++ = '\n'; + } + *p++ = '\0'; - assert(p == ret + total); + assert(p <= ret + total); return ret; } @@ -1696,9 +1727,14 @@ static float *game_colours(frontend *fe, int *ncolours) ret[COL_ERROR * 3 + 1] = 0.0F; ret[COL_ERROR * 3 + 2] = 0.0F; - ret[COL_PENCIL * 3 + 0] = 0.5F * ret[COL_BACKGROUND * 3 + 0]; - ret[COL_PENCIL * 3 + 1] = 0.5F * ret[COL_BACKGROUND * 3 + 1]; - ret[COL_PENCIL * 3 + 2] = ret[COL_BACKGROUND * 3 + 2]; + /* neap dark-mode fix: upstream pencil colour is a LIGHT blue (0.5,0.5,1.0 + * on white). The dark-mode lightness inversion then flips it to a near-black + * blue -> invisible on the dark board (the "black on black" scratch notes). + * Use a fixed mid-dark grey-blue (lightness ~0.32) that inverts to a faint + * but readable light grey-blue, distinct from the bright real digits. */ + ret[COL_PENCIL * 3 + 0] = 0.26F; + ret[COL_PENCIL * 3 + 1] = 0.26F; + ret[COL_PENCIL * 3 + 2] = 0.38F; ret[COL_DONE * 3 + 0] = ret[COL_BACKGROUND * 3 + 0] / 1.5F; ret[COL_DONE * 3 + 1] = ret[COL_BACKGROUND * 3 + 1] / 1.5F; diff --git a/windows.c b/windows.c index 58a96c7..4a2c895 100644 --- a/windows.c +++ b/windows.c @@ -37,6 +37,8 @@ #define IDM_PRINT 0x0100 #define IDM_PREFS 0x0110 +#define WF_MOVE_TIMER 2 /* neap move-injection poll (anim timer is id 1) */ + /* Menu items for preset game_params go up from IDM_PRESET_BASE in * steps of MENUITEM_STEP = 0x20. Menu items for selecting different * games (in -DCOMBINED mode) go up from IDM_GAME_BASE similarly. */ @@ -1406,6 +1408,11 @@ static frontend *frontend_new(HINSTANCE inst) printf("no window: 0x%x\n", (unsigned)lerr); } + /* neap: always-on poll of the move-injection channel (timer id 2, + * distinct from the on-demand animation timer id 1). */ + if (fe->hwnd) + SetTimer(fe->hwnd, WF_MOVE_TIMER, 150, NULL); + fe->gamemenu = NULL; fe->preset_menu = NULL; @@ -1432,6 +1439,242 @@ static bool savefile_read(void *wctx, void *buf, int len) return (ret == len); } +/* + * neap: the standard home for the game-interaction files is + * %LOCALAPPDATA%\neap\puzzle\ (persistent, user-owned, namespaced under + * neap) rather than the volatile temp root. Builds the full path for a + * leaf name, creating the directories. Returns 0 on success. + */ +static int wf_puzzle_path(char *out, size_t n, const char *leaf) +{ + char base[MAX_PATH], dir[MAX_PATH]; + DWORD r = GetEnvironmentVariableA("LOCALAPPDATA", base, sizeof(base)); + if (r == 0 || r >= sizeof(base)) + return -1; + snprintf(dir, sizeof(dir), "%s\\neap", base); + CreateDirectoryA(dir, NULL); + snprintf(dir, sizeof(dir), "%s\\neap\\puzzle", base); + CreateDirectoryA(dir, NULL); + snprintf(out, n, "%s\\%s", dir, leaf); + return 0; +} + +/* + * neap: dump the CURRENT board (the game's own text format, same as + * Copy) after every move and on every new game, so the resident reads + * the true board state instead of guessing from pixels. + * Slant format: clue rows (digit/'+' joined by '-') interleaved with + * cell rows ('|' then '\', '/', or ' '). + */ +static void wf_write_state(midend *me) +{ + char path[MAX_PATH]; + FILE *fp; + char *text; + + if (!me) + return; + if (!midend_can_format_as_text_now(me)) + return; + text = midend_text_format(me); + if (!text) + return; + if (wf_puzzle_path(path, sizeof(path), "state.txt") == 0) { + fp = fopen(path, "w"); + if (fp) { + fputs(text, fp); + fclose(fp); + } + } + sfree(text); +} + +/* + * neap: on every new game, drop the current descriptive game ID (the + * "WxH:clues" seed) to a well-known temp file, so the resident can read + * the exact puzzle natively -- no OCR, no menu clicking, no pixel math. + * Rewriting the file on each new game also makes it a SIGNAL: a watcher + * seeing the file change knows a fresh puzzle just started and can act. + * Shared frontend, so this covers every game in the collection. + */ +static void wf_write_gameid(frontend *fe, midend *me) +{ + char *id; + char path[MAX_PATH]; + FILE *fp; + + if (!me) + return; + id = midend_get_game_id(me); + if (!id) + return; + if (wf_puzzle_path(path, sizeof(path), "id.txt") == 0) { + fp = fopen(path, "w"); + if (fp) { + fputs(id, fp); + fputc('\n', fp); + /* board pixel geometry, so the resident can compute cell + * targets by arithmetic instead of pixel-hunting a snapshot. + * game coords == client coords (bitmapPosition origin is 0). */ + if (fe) { + long w = (long)(fe->bitmapPosition.right - fe->bitmapPosition.left); + long h = (long)(fe->bitmapPosition.bottom - fe->bitmapPosition.top); + if (w > 0 && h > 0) + fprintf(fp, "px %ld %ld\n", w, h); + } + fclose(fp); + } + } + sfree(id); + wf_write_state(me); /* fresh board state alongside the new id */ +} + +/* + * neap: direct move-injection channel. The resident writes moves in the + * game's OWN pixel coordinates (which equal client coords) to a temp file; + * each timer tick we consume and apply them straight through the midend -- + * no OS mouse, no window focus, no pixel-perfect cursor steering. + * Command lines (one per line): + * L left-click at game pixel (x,y) + * R right-click + * M middle-click + * new | undo | redo | solve game-level actions + */ +static void wf_click(frontend *fe, int x, int y, int down, int up) +{ + midend_process_key(fe->me, x, y, down); + midend_process_key(fe->me, x, y, up); +} + +static void new_game_type(frontend *fe); /* neap: fwd decl (defined below) */ + +/* neap: dump every preset (indextitle) to presets.txt so I can pick a + * size/difficulty by index for the "preset" command -- no Type-menu mouse. */ +static void wf_list_presets_rec(FILE *fp, struct preset_menu *menu) +{ + int i; + if (!menu) + return; + for (i = 0; i < menu->n_entries; i++) { + struct preset_menu_entry *e = &menu->entries[i]; + if (e->params) + fprintf(fp, "%d\t%s\n", e->id, e->title ? e->title : ""); + else if (e->submenu) + wf_list_presets_rec(fp, e->submenu); + } +} + +static void wf_write_presets(frontend *fe) +{ + char path[MAX_PATH]; + FILE *fp; + if (!fe || !fe->me || !fe->preset_menu) + return; + if (wf_puzzle_path(path, sizeof(path), "presets.txt") != 0) + return; + fp = fopen(path, "w"); + if (!fp) + return; + wf_list_presets_rec(fp, fe->preset_menu); + fclose(fp); +} + +static void wf_apply_move(frontend *fe, char *line) +{ + char cmd[16]; + int x = 0, y = 0; + int got; + + if (!fe || !fe->me) + return; + got = sscanf(line, "%15s %d %d", cmd, &x, &y); + if (got < 1) + return; + if (got >= 3 && strcmp(cmd, "L") == 0) + wf_click(fe, x, y, LEFT_BUTTON, LEFT_RELEASE); + else if (got >= 3 && strcmp(cmd, "R") == 0) + wf_click(fe, x, y, RIGHT_BUTTON, RIGHT_RELEASE); + else if (got >= 3 && strcmp(cmd, "M") == 0) + wf_click(fe, x, y, MIDDLE_BUTTON, MIDDLE_RELEASE); + else if (strcmp(cmd, "new") == 0) { + midend_process_key(fe->me, 0, 0, UI_NEWGAME); + wf_write_gameid(fe, fe->me); + } else if (strcmp(cmd, "undo") == 0) + midend_process_key(fe->me, 0, 0, UI_UNDO); + else if (strcmp(cmd, "redo") == 0) + midend_process_key(fe->me, 0, 0, UI_REDO); + else if (strcmp(cmd, "solve") == 0) + (void)midend_solve(fe->me); + else if (strcmp(cmd, "key") == 0) { + /* neap: inject a keystroke to the midend (number-entry games like Towers). + * Usage: "key " where tok is a single digit/letter, or a name: + * up/down/left/right bksp/del space/clear enter/return + * Typical flow: "L " to place the cursor on a cell, then "key 3". */ + char tok[16] = ""; + int k = -1; + if (sscanf(line, "%*15s %15s", tok) == 1) { + if (tok[0] != '\0' && tok[1] == '\0' && + ((tok[0] >= '0' && tok[0] <= '9') || + (tok[0] >= 'a' && tok[0] <= 'z') || + (tok[0] >= 'A' && tok[0] <= 'Z'))) + k = (unsigned char)tok[0]; + else if (strcmp(tok, "up") == 0) k = CURSOR_UP; + else if (strcmp(tok, "down") == 0) k = CURSOR_DOWN; + else if (strcmp(tok, "left") == 0) k = CURSOR_LEFT; + else if (strcmp(tok, "right") == 0) k = CURSOR_RIGHT; + else if (strcmp(tok, "bksp") == 0 || strcmp(tok, "del") == 0) k = '\b'; + else if (strcmp(tok, "space") == 0 || strcmp(tok, "clear") == 0) k = ' '; + else if (strcmp(tok, "enter") == 0 || strcmp(tok, "return") == 0) k = CURSOR_SELECT; + } + if (k != -1) + midend_process_key(fe->me, 0, 0, k); + } + else if (strcmp(cmd, "preset-list") == 0) { + /* neap: dump the size/difficulty presets to presets.txt */ + wf_write_presets(fe); + } + else if (got >= 2 && strcmp(cmd, "preset") == 0) { + /* neap: switch size/difficulty by preset index (see presets.txt) -- + * exactly the Type-menu path, no mouse. x holds the index. */ + game_params *preset = preset_menu_lookup_by_id(fe->preset_menu, x); + if (preset) { + midend_set_params(fe->me, preset); + new_game_type(fe); /* new game + resize + write id/state */ + } + } + else if (strcmp(cmd, "gameid") == 0) { + /* neap: load an EXACT board by its game-ID string: "gameid " */ + char *id = line + 6; /* skip the literal "gameid" */ + char *nl; + while (*id == ' ' || *id == '\t') id++; + nl = strpbrk(id, "\r\n"); + if (nl) *nl = '\0'; + if (*id && midend_game_id(fe->me, id) == NULL) + new_game_type(fe); + } +} + +static void wf_poll_move(frontend *fe) +{ + char path[MAX_PATH], line[256]; + FILE *fp; + + if (!fe || !fe->me) + return; + if (wf_puzzle_path(path, sizeof(path), "move.txt") != 0) + return; + fp = fopen(path, "r"); + if (!fp) + return; + while (fgets(line, sizeof(line), fp)) + wf_apply_move(fe, line); + fclose(fp); + remove(path); /* consumed -- clear the request */ + if (fe->hwnd) + InvalidateRect(fe->hwnd, NULL, FALSE); + wf_write_state(fe->me); /* refresh ground-truth after my moves */ +} + /* * Create an appropriate midend structure to go in a puzzle window, * given a game type and/or a command-line argument. @@ -1530,6 +1773,7 @@ static midend *midend_for_new_game(frontend *fe, const game *cgame, } } + wf_write_gameid(fe, me); /* neap: startup / preset / game-ID entry */ return me; } @@ -1574,6 +1818,115 @@ static void populate_preset_menu(frontend *fe, * this function. * */ +/* + * neap dark-mode: invert lightness (HSL) of every palette colour so the + * whole puzzle collection renders dark-friendly from one choke point (the + * palette loop in fe_set_midend below). Hue + saturation are preserved, so a + * red error stays red and only light<->dark flips: light backgrounds go dark, + * black ink/text goes light. + */ +static float wf_hue2rgb(float p, float q, float t) +{ + if (t < 0.0F) t += 1.0F; + if (t > 1.0F) t -= 1.0F; + if (t < 1.0F/6.0F) return p + (q - p) * 6.0F * t; + if (t < 0.5F) return q; + if (t < 2.0F/3.0F) return p + (q - p) * (2.0F/3.0F - t) * 6.0F; + return p; +} +static void wf_dark_mode_colour(float *rp, float *gp, float *bp) +{ + float r = *rp, g = *gp, b = *bp; + float max = r > g ? (r > b ? r : b) : (g > b ? g : b); + float min = r < g ? (r < b ? r : b) : (g < b ? g : b); + float l = (max + min) / 2.0F; + float nl = 1.0F - l; /* inverted lightness */ + if (max == min) { /* achromatic (grey) */ + *rp = *gp = *bp = nl; + } else { + float d = max - min; + float s = (l > 0.5F) ? d / (2.0F - max - min) : d / (max + min); + float h, q, p; + if (max == r) h = (g - b) / d + (g < b ? 6.0F : 0.0F); + else if (max == g) h = (b - r) / d + 2.0F; + else h = (r - g) / d + 4.0F; + h /= 6.0F; + q = (nl < 0.5F) ? nl * (1.0F + s) : nl + s - nl * s; + p = 2.0F * nl - q; + *rp = wf_hue2rgb(p, q, h + 1.0F/3.0F); + *gp = wf_hue2rgb(p, q, h); + *bp = wf_hue2rgb(p, q, h - 1.0F/3.0F); + } +} + +/* + * neap dark chrome: opt each top-level window into Windows dark mode for the + * TITLE BAR (DwmSetWindowAttribute) and the MENUS (the undocumented-but-standard + * uxtheme SetPreferredAppMode / FlushMenuThemes path that Explorer & Terminal use), + * so the native frame honors the system dark theme instead of staying white while + * the board is dark. Both DLLs are loaded dynamically -> no header/link changes. + */ +static void wf_dark_chrome(HWND hwnd) +{ + typedef int (WINAPI *SetPreferredAppMode_t)(int); + typedef BOOL (WINAPI *AllowDarkModeForWindow_t)(HWND, BOOL); + typedef void (WINAPI *FlushMenuThemes_t)(void); + typedef void (WINAPI *RefreshImmersive_t)(void); + typedef HRESULT (WINAPI *DwmSetWindowAttribute_t)(HWND, DWORD, LPCVOID, DWORD); + + static int inited = 0; + static AllowDarkModeForWindow_t pAllowDark = NULL; + static FlushMenuThemes_t pFlushMenu = NULL; + static DwmSetWindowAttribute_t pDwmSet = NULL; + + if (!inited) { + inited = 1; + HMODULE hUx = LoadLibraryExW(L"uxtheme.dll", NULL, LOAD_LIBRARY_SEARCH_SYSTEM32); + if (hUx) { + SetPreferredAppMode_t pSet = + (SetPreferredAppMode_t)GetProcAddress(hUx, MAKEINTRESOURCEA(135)); + RefreshImmersive_t pRefresh = + (RefreshImmersive_t)GetProcAddress(hUx, MAKEINTRESOURCEA(104)); + pAllowDark = (AllowDarkModeForWindow_t)GetProcAddress(hUx, MAKEINTRESOURCEA(133)); + pFlushMenu = (FlushMenuThemes_t)GetProcAddress(hUx, MAKEINTRESOURCEA(136)); + if (pSet) pSet(2 /* ForceDark */); + if (pRefresh) pRefresh(); + } + HMODULE hDwm = LoadLibraryExW(L"dwmapi.dll", NULL, LOAD_LIBRARY_SEARCH_SYSTEM32); + if (hDwm) + pDwmSet = (DwmSetWindowAttribute_t)GetProcAddress(hDwm, "DwmSetWindowAttribute"); + } + + if (pAllowDark) pAllowDark(hwnd, TRUE); + if (pDwmSet) { + BOOL dark = TRUE; + /* DWMWA_USE_IMMERSIVE_DARK_MODE = 20 (Win10 1903+/Win11), 19 on 1809..1903 */ + if (FAILED(pDwmSet(hwnd, 20, &dark, sizeof(dark)))) + pDwmSet(hwnd, 19, &dark, sizeof(dark)); + } + if (pFlushMenu) pFlushMenu(); +} + +/* neap: dark the bottom status bar (SetWindowTheme dark + a dark backdrop), + * so the footer strip matches the board instead of staying white. */ +static void wf_dark_statusbar(HWND hstatus) +{ + typedef HRESULT (WINAPI *SetWindowTheme_t)(HWND, LPCWSTR, LPCWSTR); + static int inited = 0; + static SetWindowTheme_t pSetTheme = NULL; + if (!hstatus) return; + if (!inited) { + inited = 1; + HMODULE hUx = GetModuleHandleW(L"uxtheme.dll"); + if (!hUx) hUx = LoadLibraryExW(L"uxtheme.dll", NULL, LOAD_LIBRARY_SEARCH_SYSTEM32); + if (hUx) pSetTheme = (SetWindowTheme_t)GetProcAddress(hUx, "SetWindowTheme"); + } + /* Remove the visual style so SB_SETBKCOLOR is honored (a themed status bar + * ignores it), then set a dark backdrop. */ + if (pSetTheme) pSetTheme(hstatus, L"", L""); + SendMessageW(hstatus, SB_SETBKCOLOR, 0, (LPARAM)RGB(32, 32, 32)); +} + static int fe_set_midend(frontend *fe, midend *me) { int x, y; @@ -1602,9 +1955,9 @@ static int fe_set_midend(frontend *fe, midend *me) fe->pens = snewn(ncolours, HPEN); for (i = 0; i < ncolours; i++) { - fe->colours[i] = RGB(255 * colours[i*3+0], - 255 * colours[i*3+1], - 255 * colours[i*3+2]); + float cr = colours[i*3+0], cg = colours[i*3+1], cb = colours[i*3+2]; + wf_dark_mode_colour(&cr, &cg, &cb); /* neap whole-set dark mode */ + fe->colours[i] = RGB(255 * cr, 255 * cg, 255 * cb); fe->brushes[i] = CreateSolidBrush(fe->colours[i]); fe->pens[i] = CreatePen(PS_SOLID, 1, fe->colours[i]); } @@ -1619,6 +1972,7 @@ static int fe_set_midend(frontend *fe, midend *me) WS_CHILD | WS_VISIBLE, 0, 0, 0, 0, /* status bar does these */ NULL, NULL, fe->inst, NULL); + wf_dark_statusbar(fe->statusbar); /* neap: dark footer */ } else fe->statusbar = NULL; @@ -1751,6 +2105,7 @@ static int fe_set_midend(frontend *fe, midend *me) } DestroyMenu(oldmenu); SetMenu(fe->hwnd, bar); + wf_dark_chrome(fe->hwnd); /* neap: dark title bar + menus */ get_menu_size(fe->hwnd, &menusize); fe->xmin = (menusize.right - menusize.left) + 25; } @@ -2649,6 +3004,7 @@ static void new_game_type(frontend *fe) new_game_size(fe, 1.0); update_type_menu_tick(fe); update_copy_menu_greying(fe); + wf_write_gameid(fe, fe->me);/* neap: config / Random Seed / Specific / type change */ } static bool is_alt_pressed(void) @@ -2664,6 +3020,55 @@ static bool is_alt_pressed(void) return false; } +/* + * neap: dark the always-visible menu BAR via the undocumented UAH draw + * messages (SetPreferredAppMode themes the popups, but not the bar strip). + * Structs/constants are the community-documented shapes for these messages. + */ +#ifndef WM_UAHDRAWMENU +#define WM_UAHDRAWMENU 0x0091 +#define WM_UAHDRAWMENUITEM 0x0092 +#endif +typedef union tagUAHMENUITEMMETRICS { + struct { DWORD cx; DWORD cy; } rgsizeBar[2]; + struct { DWORD cx; DWORD cy; } rgsizePopup[4]; +} UAHMENUITEMMETRICS; +typedef struct tagUAHMENUPOPUPMETRICS { DWORD rgcx[4]; DWORD fUpdateMaxWidths : 2; } UAHMENUPOPUPMETRICS; +typedef struct tagUAHMENU { HMENU hmenu; HDC hdc; DWORD dwFlags; } UAHMENU; +typedef struct tagUAHMENUITEM { int iPosition; UAHMENUITEMMETRICS umim; UAHMENUPOPUPMETRICS umpm; } UAHMENUITEM; +typedef struct tagUAHDRAWMENUITEM { DRAWITEMSTRUCT dis; UAHMENU um; UAHMENUITEM umi; } UAHDRAWMENUITEM; + +static HBRUSH wf_menubar_bg(void) { static HBRUSH b = NULL; if (!b) b = CreateSolidBrush(RGB(32,32,32)); return b; } +static HBRUSH wf_menubar_hot(void) { static HBRUSH b = NULL; if (!b) b = CreateSolidBrush(RGB(64,64,64)); return b; } + +static int wf_uah_draw_menu(HWND hwnd, UAHMENU *pudm) +{ + MENUBARINFO mbi; RECT rcWin, rc; + memset(&mbi, 0, sizeof(mbi)); mbi.cbSize = sizeof(mbi); + if (!GetMenuBarInfo(hwnd, OBJID_MENU, 0, &mbi)) return 0; + GetWindowRect(hwnd, &rcWin); + rc = mbi.rcBar; + OffsetRect(&rc, -rcWin.left, -rcWin.top); + FillRect(pudm->hdc, &rc, wf_menubar_bg()); + return 1; +} +static int wf_uah_draw_menuitem(HWND hwnd, UAHDRAWMENUITEM *pudmi) +{ + wchar_t buf[128]; MENUITEMINFOW mii; + UINT fmt = DT_CENTER | DT_SINGLELINE | DT_VCENTER; + (void)hwnd; + memset(&mii, 0, sizeof(mii)); + mii.cbSize = sizeof(mii); mii.fMask = MIIM_STRING; mii.dwTypeData = buf; mii.cch = 127; buf[0] = 0; + GetMenuItemInfoW(pudmi->um.hmenu, pudmi->umi.iPosition, TRUE, &mii); + FillRect(pudmi->um.hdc, &pudmi->dis.rcItem, + (pudmi->dis.itemState & (ODS_HOTLIGHT | ODS_SELECTED)) ? wf_menubar_hot() : wf_menubar_bg()); + SetBkMode(pudmi->um.hdc, TRANSPARENT); + SetTextColor(pudmi->um.hdc, + (pudmi->dis.itemState & (ODS_GRAYED | ODS_DISABLED)) ? RGB(120,120,120) : RGB(230,230,230)); + DrawTextW(pudmi->um.hdc, buf, -1, &pudmi->dis.rcItem, fmt); + return 1; +} + static LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) { @@ -2674,12 +3079,19 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT message, case WM_CLOSE: DestroyWindow(hwnd); return 0; + case WM_UAHDRAWMENU: /* neap: dark menu bar */ + if (wf_uah_draw_menu(hwnd, (UAHMENU *)lParam)) return 0; + break; + case WM_UAHDRAWMENUITEM: + if (wf_uah_draw_menuitem(hwnd, (UAHDRAWMENUITEM *)lParam)) return 0; + break; case WM_COMMAND: cmd = wParam & ~0xF; /* low 4 bits reserved to Windows */ switch (cmd) { case IDM_NEW: if (midend_process_key(fe->me, 0, 0, UI_NEWGAME) == PKR_QUIT) PostQuitMessage(0); + wf_write_gameid(fe, fe->me);/* neap: menu New */ break; case IDM_RESTART: midend_restart_game(fe->me); @@ -3062,6 +3474,7 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT message, PostQuitMessage(0); ReleaseCapture(); + wf_write_state(fe->me); /* capture the operator's own move */ } break; case WM_MOUSEMOVE: @@ -3097,7 +3510,9 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT message, } return 0; case WM_TIMER: - if (fe->timer) { + if (wParam == WF_MOVE_TIMER) { + wf_poll_move(fe); /* neap: apply any queued moves */ + } else if (fe->timer) { DWORD now = GetTickCount(); float elapsed = (float) (now - fe->timer_last_tickcount) * 0.001F; midend_timer(fe->me, elapsed);