Repository navigation
Conversation
Experimental implementation of SGF replay/navigation feature: - Navigate loaded SGF games with Arrow keys (Left/Right) and Backspace - Auto-skip PASS moves at end of finished games - Display all variations as board overlays with letter suffixes (15a, 15b) - Click on variation overlay to follow that branch - Create new branches by playing different moves during navigation - Keep all engines in sync during navigation for seamless continuation - Hide stale territory display when navigating back - Protect navigation state from accidental clicks (ignore non-variation clicks) Key changes: - GameRecord: loadedMoves/viewPosition state, getVariations(), navigateToChild() - GameThread: navigateBack(), navigateForward(), navigateToVariation() - GobanView: onStonePlaced event, updateNavigationOverlay() - GobanControl: keyboard navigation, click-to-navigate for variations - Board: toggleTerritoryAuto(), fixed removeBoardOverlay layer check - Remapped gamma/contrast keys from arrows to brackets Refs #47 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Thread Safety (volatile → std::atomic): - GameThread.h: interruptRequested, hasThreadRunning - Board.h: positionNumber, moveNumber - GobanModel.h: isGameOver - Remove unnecessary volatile from Color::col (value type) - Use .load()/.store() for atomic operations in GobanView/Board - Capture Board by reference in lambdas (atomics non-copyable) Null Safety: - Add null check for currentCoach() in setFixedHandicap() - Handle null kibitzEngine gracefully in gameLoop() Code Deduplication: - Extract GameRecord::extractMoveFromNode() helper - Refactor loadFromSGF(), getVariations(), navigateToChild() - ~40 lines of duplicated SGF parsing code removed Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add cidAnnotation constant for empty points with hidden grid - Annotation patches larger (0.4) to cover grid cross - Use board material for seamless wood texture blending Part of SGF navigation feature (Refs #47) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Fix overlay showing wrong move number during SGF navigation - Use viewPosition instead of history.size() when navigating - Apply fix to both onStonePlaced() and updateLastMoveOverlay() - Allow play/kibitz when at end of loaded SGF (isAtEndOfNavigation) - Reset navigation state when starting new game (clear loadedMoves) - Extract processUndo() helper from gameLoop (~25 lines → 4 lines) - Remove obsolete koOverlay capture annotation code Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Further simplify gameLoop by extracting: - syncOtherEngines(): propagate moves to non-coach engines - notifyMoveComplete(): notify observers with board state and comments The success block in gameLoop is now ~15 lines instead of ~30. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Replace manual new/delete of std::thread with std::unique_ptr: - thread member: std::thread* → std::unique_ptr<std::thread> - run(): new std::thread → std::make_unique<std::thread> - interrupt(): delete thread → thread.reset() Explicit ownership, exception-safe, no manual cleanup needed. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Refactor GameRecord to use SGF tree as single source of truth - Remove redundant history vector, loadedMoves, viewPosition - Add tree traversal helpers (getTreeDepth, getPathFromRoot, isAtRoot) - Add getColorToMove() derived from SGF tree position - Add full replay navigation support - navigateToStart() - jump to beginning (Home key) - navigateToEnd() - jump to end with territory display (End key) - Step through passes individually with "Black/White passes" messages - Show territory at end of finished games - Add Replay menu section to all language files (en/cs/zh/ja/ko) - First, Previous, Next, Last navigation commands - Rename Move subsection to Play for clarity - Fix board state sync in showterritory() - Refresh board from GTP engine before calculating territory - Fixes empty board display after navigate to start/end - Update key bindings - Home (89) -> navigate_start - End (88) -> navigate_end - Backslash (46) -> reset contrast and gamma (moved from Home) - Add shader coordinate system documentation to CLAUDE.md Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Right arrow now continues on main line even when multiple variations exist, making back-and-forth navigation smoother without mouse. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Match SGF player names (PB/PW) to existing engines; create temporary SGF players for unmatched names that behave like humans - Add SGF_PLAYER type flag to mark temporary players for cleanup - Fix dropdown selection by saving values before UI clearing (avoids side effects from onchange event handlers) - Update numPlayers after adding SGF players to prevent modulo wrap - Remove SGF players on new game or loading different SGF - Display SGF comments (C property) in scrollable bottom panel - Use SetScrollTop to scroll to bottom showing latest content - Add message-container class to all language RML files Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Parse LB (labels), TR (triangles), SQ (squares), CR (circles), MA (marks) - Render labels on stones using stone-level overlay - Render labels on empty points with grid-hiding patch - Explicit markup takes precedence over auto-generated variation markers - Extended glyph cache to support A-Z, a-z, and symbol characters - Update markup display during SGF navigation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Use glfwWaitEvents() when idle instead of busy-polling - Only render when needsRender() returns true (after event processing) - Add mouseover/mouseout listeners for UI hover state updates - Lazy-initialize PortAudio on first sound (not at startup) - Properly restart audio stream after completion state - Shutdown PortAudio when idle to release pipewire connection - Demote ALSA warnings to debug level Fixes #52 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Trigger repaint on window resize callback for immediate response - Add requestRepaint() to fullscreen toggle command - Demote GLFW_FEATURE_UNAVAILABLE errors to debug (Wayland limitations) - Demote other GLFW errors to warn level Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Bring in GPU idle usage fixes to the feature branch.
Adds a global HoverRepaintListener that automatically registers mouseover/mouseout handlers on all documents in the context. This ensures hover states work correctly for dialogs (like the file chooser) with event-driven rendering. Removes redundant hardcoded hover listeners from ElementGame since the global listener now handles all documents automatically. Fixes #52 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The updateFlag was accessed from both the main thread (UI/navigation) and the GameThread worker thread (game logic/AI) without synchronization. This could cause timing-dependent issues like double sound playback. Using std::atomic<int> ensures thread-safe access to the flag. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…ing (#47) - Add NavigationGuard RAII pattern to block genmove during navigation - Fix isThinking() to only block for ENGINE types, not human players - Fix Escape key being blocked during navigation (only block nav keys) - Fix spurious "switched_player:" annotations during SGF loading - Fix Space key not triggering kibitz at end of unfinished branches - Add isGameFinished() to detect resign/double-pass game states - Add isMainLineFinished() to check if main line ends in finished state - Remove RE property when saving modified SGF with unfinished main line Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Update PB/PW in SGF header when first move is made (captures actual players) - Only annotate player switches after first move (ignore setup changes) - Space key now starts engine if not running (consistent with click/Start) - Document design invariants and test scenarios in CLAUDE.md Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Create GameNavigator class to handle SGF tree navigation operations - Reduce GameThread.cpp by ~300 lines (navigation logic moved) - GameNavigator uses callbacks for engine access (loose coupling) - navigateToVariation returns VariationResult for Analysis mode handling - GameThread retains Analysis mode responsibility (separation of concerns) Part of Phase 4 refactoring from code-review-2026-01.md Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Separate player/engine lifecycle management from game loop: - Create PlayerManager class handling: - Player/engine registration (addEngine, addPlayer) - Role management (setRole, activatePlayer, getActivePlayer) - Current player queries (currentCoach, currentKibitz, currentPlayer) - Engine loading from config (loadEngines) - SGF player cleanup (removeSgfPlayers) - Helper: areBothPlayersHuman() for Analysis mode checks - GameThread now delegates all player management to PlayerManager - GameNavigator updated to use const PlayerList& reference - GameThread reduced from ~967 to ~830 lines This completes Phase 4 of the code review refactoring. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The mouseover/mouseout events handle CSS :hover state changes for showing submenus, but RmlUi select dropdowns also need the Click event to trigger repaints when toggling. This fixes laggy submenu appearance. Minimal event set: mouseover, mouseout, click (3 events). Fixes #52 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Handicap stone application: - Extract applyHandicapStonesToEngines() helper to eliminate duplication - Simplify setFixedHandicap() and setHandicapStones() to use shared logic - Both functions now share: syncing stones to engines, setting colorToMove Integer type consistency: - Standardize on size_t (without std:: prefix) throughout codebase - Fix PlayerManager.h/cpp, GobanOverlay.cpp, GobanShader.cpp Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Resignation fixes: - Record resignation as RE property (W+R/B+R) in SGF root node - Set winner immediately in onGameMove when resignation detected - Fix result() to handle both live games and SGF loading paths - Update isMainLineFinished() to check RE property for resignation - Prevent finalizeGame() from overwriting resignation result SGF loading fixes: - Set state.winner before calling result() during SGF load - Fix message label not updating when loading subsequent SGF games (detect game change via positionNumber) Player preservation: - Fix removeSgfPlayers() to preserve player selection on new game - Only reset to defaults if active players were SGF players Debug logging: - Add navigation state logging for future debugging Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Adjust menuitem padding and hover background for consistency - Fix submenu positioning to connect seamlessly with parent menus - Remove unused .menu.hoverable and #blackMoves styles Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…ends - Add isAtFinishedGame(), shouldShowTerritory(), isResignationResult(), getResultMessage() methods to GameRecord for unified game state detection - Remove RE property when creating new variation (maintains invariant that RE reflects current main line result only) - Fix playerToMove not being cleared when game ends, which blocked navigation - Clear model.state.reason when creating new branch to update menu state - Show resignation message when navigating to end of resigned game - Use isAtFinishedGame() instead of isGameFinished() for space key blocking - Improve navigation logging for debugging sync issues Fixes #52 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Consolidate two right-aligned menus into one to save horizontal space in all language variants (en, cs, zh, ja, ko). Adjust CSS for proper selectbox positioning in right-aligned menus. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add Sound toggle to mute/unmute stone placement sounds - Persistent setting saved in user.json - AudioPlayer mute control - Menu item in Options menu (all 5 languages) - Fix FPS toggle visual feedback - Rename command to toggle_fps for consistency - Add toggle_fps class to menu item - Add startup sync for initial state Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add player turn indicators using toggle style on dropdowns - Fix FPS toggle visual feedback (was missing toggle_fps class) - Fix FPS display showing stale value when idle in event-driven mode - Use glfwWaitEventsTimeout for one final update after activity stops - Properly settles to 0 fps via 60→1→0 sequence - Move Sound toggle from View to Options menu (all languages) - Change mousemove event logging from debug to trace level - Remove unused .active CSS class and header IDs Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Daily session files: games saved to YYYY-MM-DD.sgf instead of per-launch files - Game start timestamp: DT property now includes full datetime (YYYY-MM-DD HH:MM:SS) - Restore game on startup: last game automatically loaded on app restart - Language switch persistence: game saved before restart, restored after - Day-change detection: new games on a new day create new daily file - Player matching: restored games activate matching engines by name Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add "Archive" menu item to explicitly start a new session file with a timestamped name, while preserving the current daily session. Persist game settings (board size, komi, handicap, players) to user.json and restore them on startup. SGF settings take precedence when loading a saved game file. Fix player settings restoration by suppressing saves during initialization and syncing dropdown UI after applying settings. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…lasses Two eyes shared one depth buffer, and one number per pixel cannot describe two occlusions. min(dl, dr) classified a pixel as a stone wherever *either* eye saw one, so the other eye's annotation was clipped along a silhouette it should have been drawn past — a best-move letter with its right-hand side missing, reported from a real session. max() only swaps that for text painted over a stone. The comment above the line said as much: it was the least-bad answer, not a fix. Render one eye per pass instead, with a depth clear between, and sum them. Each eye then tests against its own board. It costs nothing — the shader always called render() twice per fragment and two passes call it once each — and measured on tests/bench/ it is *faster*: mono unchanged at 28.8 fps, stereo 12.4 -> 14.9, occupancy paying for the extra draw. GobanOverlay::draw() takes the eye from its caller for the same reason: each eye's text must meet that eye's depth. That structure then made the composite a choice rather than a constant, so: - Four modes (Stereo::Anaglyph): gray, half-color, color, dubois. gray stays the default and is the only one that leaves green alone, which is what lets it work in red/blue glasses as well as red/cyan. Putting the right eye's image into green ghosted immediately on real glasses — a red filter blocks blue well and green badly — and that shipped for one build here. - Stereo::Glasses, because which eye owns green is a property of the *lenses*, not of the mode: a cyan lens passes green so it belongs to the right eye, a blue lens blocks it while the red one leaks it so it belongs to the left. Whichever eye holds two channels is the only eye that can carry hue, and that flips. Getting it backwards delivers one eye's picture to the other, which is a second image rather than a wrong colour. - Three dials for filters that are not ideal. anaglyph_strength desaturates each eye toward its own brightness, since ghosting and rivalry both scale with how different the two images are. anaglyph_leak cancels the crosstalk outright, each eye pre-subtracting its image from the channels the *other* eye reads — light arriving through the wrong filter cannot be removed from a channel that eye cannot see, so each component names the channel it corrects. anaglyph_balance is per-eye gain, for a blue lens that is much darker than its red partner. Those last two go negative, which is the whole point of a cancellation, and a fixed-point framebuffer clamps a negative fragment to zero before the additive blend ever sees it: measured, clamped Dubois gave (0.689, 0.778, 0.230) against an exact (0.655, 0.703, 0.150). StereoComposite accumulates exactly those configurations in RGBA16F and resolves with a blit, the blit being the clamp, once, at the end. Everything else stays on the direct path, which is cheaper and already verified through real glasses. Also fixed along the way: Dubois' matrices were built column-wise from row-ordered coefficients, silently transposing them into a different projection that still looked plausible; and setEye()/setAnaglyph() were called before use(), so glUniform wrote them nowhere and every mode rendered as mode zero. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The clean model says green belongs to one eye or the other — a cyan lens passes it, a blue lens blocks it. Cheap dyed lenses do not honour that: their passbands are broad and overlapping, and green sits in the middle of the visible spectrum. Measured on one such pair, symmetrically: giving green to the right eye put a second picture in the RED lens, and giving it to the left put a second picture in the BLUE lens. Green was reaching both eyes whoever owned it. With those lenses only red and blue are cleanly separated — one scalar per eye — and hue needs two, so colour is genuinely impossible and `gray` is the correct answer rather than a fallback. Its flat green turns out to be exactly right, and now for a reason. Short of that, the ghost is proportional to how much green is present, so this scales it: 1 is the mode as published, 0 removes green entirely, and somewhere between is the most colour a given pair of lenses will carry. On a wooden board the cost is mild, since suppressing green only skews the image warm. It is not anaglyph_strength, which is why it had to be a second dial rather than a wider range on the first: strength desaturates toward luminance, and luminance has *full* green. Measured under red/blue half-colour, strength 0 left mean green at 106 of 255 where green 0 took it to 0.1. One moves colour toward grey; the other moves the disputed channel toward black, and only the second touches this ghost. The crosstalk terms scale with it too — they cancel a ghost whose size this dial just changed, and a correction fitted at full green would cut a hole at half of it. Inert in gray, and the command says so rather than leaving a user to tune a knob that cannot move in the mode most likely to be selected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things, all the same shape: something that carries no picture being treated
as though it did, and live experiments reaching the file a fresh install reads.
Gray holds green at a constant that carries neither eye — the whole of why it
survives lenses the colour modes cannot. The crosstalk correction was aimed at it
anyway, so a non-zero anaglyph_leak.g there subtracted one eye's *shape* from a
flat field: a ghost manufactured in the mode selected for having none. A constant
that leaks is a uniform brightness offset, not a second picture.
Prompted by the observation that a red lens leaks blue as well as green, which is
real — dyed red filters commonly have a secondary transmission window in deep
blue/violet, and a display's blue primary is not monochromatic. That case is
already served by anaglyph_leak's r term, and it is the one correction that
applies in gray, the right eye's image being carried in blue in every
arrangement. Verified: anaglyph_leak 0.08 0 0 drops mean red by 12.0 against a
predicted 0.08 x 147.9 = 11.8, with green and blue untouched.
And config/base.json goes back to what it is meant to ship. Two values tuned by
eye had reached it, one of them through a commit of mine that had already noticed
and mentioned it, which is worse than not noticing:
- move_quality was #008000/#808000/#800000, at mean brightness 0.167, 0.335,
0.167. Non-monotonic, so best and worst are the same grey once eyeInk()
collapses a label under a stereo shader — precisely the defect the palette
change was made to fix, shipped by the change that fixed it.
- anaglyph_green was 0, which silently removes colour from every colour mode
for anyone who has not edited their own config.
Both are back to the documented defaults, and the invariant now says plainly that
base.json is shipped and tracked while a live experiment belongs in user.json,
where every one of these commands already writes.
res/stereo.zip is gitignored, so the 8.6 MB of source PDFs cannot be committed by
an absent-minded `git add -A`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The last thing in an anaglyph that sits at the wrong depth. A native pointer is composited by the window system at the screen plane with no disparity at all, so it can never be at the depth of the point it indicates: fuse the board and there are two pointers, fuse the pointer and the board doubles. Nothing tunes that — a 2D overlay has no depth — so the only remedy is to draw the pointer in the scene. It rides the grid's own coverage accumulator in scene/object/board.glsl, which gives it each eye's disparity, the board's antialiasing and its occlusion by stones with no second code path, and inks it like a grid line because it is the same act: a mark on the wood. Shape is constrained by what this board already means. A disc is a stone and an upright cross is the grid, so both are camouflage; the mark is four ticks turned a quarter turn from the grid, gapped at the centre so the intersection it names stays clear, and reaching past a stone's radius so it still reads on an occupied point. It lies flat rather than floating in a plane above the wood, which was the tempting alternative: a floating marker has different disparity from the point beneath it, so the eyes get two fusion targets a few pixels apart, which is the discomfort this whole series has been removing. And it snaps to the intersection. Position carries the continuous ray hit, so the first version slid smoothly between the lines — naming a place the board has no name for, where the ghost stone and the click that follows both snap already. The native pointer is hidden positionally, never globally: only over the board, and only under a stereo shader. Over the RmlUi interface it is *correct*, that interface being flat at the screen plane itself, so it is never taken away where it works — and in mono it is never taken away at all, there being no mismatch to answer and a second indicator being clutter. Two things found while wiring it. The cursor uniform was uploaded inside UPDATE_STONES, and the mouse-move path raises that flag only while a stone is in hand, so it went stale in exactly the case this feature is for; it moves out beside the camera. And the over-the-board gate has to be settled in moveCursor() rather than deferred to the next OnUpdate(), or it describes where the mouse was — which the new scenario caught immediately, asserting straight after a move. mouse_move is a new scripting command, since the harness could not hover anything before this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… in mono Three refinements to the drawn pointer, all from watching it. It sat exactly on the intersection, which read as a cursor stuck to a lattice. The stone in hand has never done that: Board::placeCursor() offsets it by where inside the cell the ray actually fell, an imprecise hand that slides as the mouse moves and only then jumps a point. The mark now takes the same offset from the same function — fuzzyOffset(), factored out rather than copied, because two implementations would drift apart and the drift would be visible as a mark sitting off the stone that lands on it. Measured with the shipped constants, about 1 px of drift per 8 px of mouse travel. It also drew itself while a stone was in hand, which is two indicators for one point. It now stands down wherever the ghost stone is already showing where the click will land — one predicate, ghostStoneVisible(), read both by the code that places the ghost and by the code that decides to stay out of its way, so they cannot disagree and draw both. But it does *not* stand down merely because a stone is held. On a point the rules refuse there is no ghost stone, and the native pointer is hidden, so that would leave no pointer at all; its presence there is what says "not here". For the same reason the hiding decision reads diegeticPointer() rather than pointerMark() — reading the mark alone handed the native pointer back the instant you took a stone out of the bowl. And the stereo-only gate becomes a mode. The drawn pointer turns out to be worth having in mono too, so PointerMode is Auto / Always / Never with a sticky `pointer` command. Auto stays the default and means "where the native pointer is actually wrong", which is a stereo shader; Always also takes the native pointer away in mono, and that is a change nobody asked for by default. mouse_click joins mouse_move as a scripting command. The scenario needs to click the point it is hovering without knowing which point that is — the pixel-to-point mapping depends on the window size, so hard-coding it made the test assert against a different intersection under the harness than in a hand-run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A board-level annotation — a move number on an empty point, a variation label, an evaluation letter — is drawn on a quad of clean board laid over the grid, so the label has somewhere legible to sit. That patch hid the pointer exactly as it hides the lines, and since it reaches 0.4 of a spacing while the ticks run from 0.34 to 0.72 along the diagonal, what survived was the outer stub of each one. Which is precisely what looked wrong. Moving the ticks outside the patch is not available. The neighbouring patch starts at 0.6, so they would have to live in a corridor 0.2 of a spacing wide, and the imprecise-hand offset drifts the whole mark within the cell — they would have to be stubs by design to stay inside it, which is the thing being fixed. So the patch draws the mark itself. shade.glsl blends the object's material toward materials[pid] by a.x, which is how a grid line is inked in the first place, so naming idBlackStone and putting the coverage in a.x is the same act the board already performs. With no pointer over it the coverage is zero and the patch renders exactly as before. pointerCoverage() moves to api.glsl for that: one implementation, two callers, because a second copy would let the wood and the patch disagree about where the mark is — and they meet along an edge, where the disagreement would show. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The release Build workflow last ran in February, on a different branch. Fired
on `ux` for the first time since, it failed on both non-Linux platforms — none
of it new code, all of it accumulated rot in the build system. `test.yml` could
not have caught any of it: it builds `goban_tests`, which links `goban_core`
and no renderer, no RmlUi backend, no audio and no main().
Four causes:
* **windows.h defines `min` and `max` as macros**, turning every `std::min(...)`
and `std::numeric_limits<T>::max()` into "C2589: '(': illegal token on right
side of '::'". main.cpp defined NOMINMAX for itself, so this stayed hidden
until something in a *header* used them — GtpClient::scoringTimeout(), the
move-quality ramp in AnalysisService, and libsgfcplusplus's SgfcMessageID.h.
It belongs in add_definitions, once, for every target.
* **/SUBSYSTEM:WINDOWS was set in CMAKE_EXE_LINKER_FLAGS**, which is global, so
it applied to mock_gtp_engine too and sent the linker looking for WinMain in a
console program with a plain main(). The application wants that subsystem and
already says so properly, per target, via WIN32_EXECUTABLE.
* **iconv was linked through EXTRA_LIBS**, which goban_tests does not use, so on
macOS the application built and the tests did not (`_iconv_open`, arm64). It
belongs on goban_core, PUBLIC — that is the target pulling in libsgfcplusplus.
* **RmlUi 6.2's GL2 backend includes <AGL/agl.h>**, a framework Apple removed,
so no macOS build gets past it on a current SDK. Nothing in the file uses AGL;
upstream deleted the line in 6.3.
The RmlUi bump is the only one that costs anything, and both costs are paid here
rather than worked around. SystemInterface_GLFW now takes the window at
construction, so it can no longer be a file-scope static — it becomes an optional
constructed after the window exists and reset before glfwTerminate(), since its
destructor hands the cursors back to GLFW. And it creates all six standard
cursors up front, which on X11 raises GLFW_CURSOR_UNAVAILABLE for any the theme
lacks: two warnings and a message badge at every single startup.
That last one is demoted rather than tolerated. The three cursors that can fail
are `move`, `resize` and `unavailable`, and no stylesheet here asks for any of
them — the only cursor this interface uses is `pointer`, which is always
available. GLFW returns null and the backend sets no cursor, so the effect is
nil, and GlfwErrorCallback already demotes GLFW_FEATURE_UNAVAILABLE for exactly
this reason. A benign, unactionable condition must not spend the badge on the
one panel a user is meant to trust.
Staying on 6.2 with an empty AGL/agl.h on the macOS include path was tried and
rejected: it fixes one platform by carrying a shim for a dependency we would
rather keep current.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things were laid over the board saying what the board could say itself, and
both are now drawn in the wood margin through the overlay's glyph pass — the one
layer every shader has, and the one that gives each eye of a stereo shader its
own copy with the board's own depth and disparity.
**The evaluation panel is retired.** #grpAnalysis, its stylesheet block, its
three templates and syncEvaluationPanel() are gone, and with them the placement
toggle: toggle_evaluation_board, GobanView::showEvaluationOnBoard and
UserSettings::evaluationOnBoard. The on-board readout arrived as an experiment
and said so in its own doc comment; it shipped behind a toggle defaulting off,
which meant the *worse* surface was what every user, screenshot and scenario
actually got. A choice between two renderings of one fact is not a feature.
toggle_evaluation now decides whether the evaluation exists; evaluation_align
still decides where along the edge it sits, because that is taste.
**The engine-thinking and engine-syncing banners are gone too**, replaced by a
mark and an elapsed second count in the margin. #lblStatus keeps only what is
about the *application*: which engine is still loading, and the message badge.
The rule both halves obey, and the reason the first attempt was thrown away:
**a board annotation is carved, not lit.** No pulsing, no fading, no dimming. An
animated opacity reads as a screen effect laid over the scene rather than as
part of it, which is the one quality a diegetic display exists to have. A
breathing mark was built, tested and removed on this rule; the blink curve and
its tests went with it. Motion is allowed when it is *physical* — the count
ticking over is what a clock beside a board does, and it doubles as the repaint
gate, so a wait now costs one frame per second instead of the twenty
getIdleTimeout() offers.
The same rule explains annotations.readout_stale_color shipping fully
transparent, which deliberately reverses ADR-0007 decision 13 ("dimmed rather
than blanked, because blanking would flicker once per move"). The flicker is
accepted; a half-faded number reads as a fault. That is a decision made by
looking at it, and CLAUDE.md now says not to fix it back.
Two details worth keeping:
* **The 0.5 s grace before anything is drawn is not decoration.** GNU Go answers
a genmove in 13 ms, so without it every move of a bot-versus-bot match flashes
the mark for a single frame — precisely the "something is broken" reading the
indicator exists to prevent. The clock runs from the true start, so the first
count shown is honest.
* **The glyph atlas was a silent gate and is now loud.** It was a string
literal, so a character not in it simply did not appear, with nothing said
anywhere. Survivable while every drawable string was written in C++; not once
annotations.wait_glyph lets someone name one. Wait::atlasWith() composes it
from the configuration and the font is asked about every character. This
matters more than it sounds: the shipped overlay font is Roboto, 98 glyphs,
ASCII only — no U+25CF, no U+25CB, not even a bullet. Only the CJK font that
ships for zh/ja/ko has them, which is why the default mark is "O" and a real
stone needs fonts.overlay pointed elsewhere.
The pure halves live in src/WaitIndicator.h — what the indicator shows at a
given moment, and the atlas composition — so they test without a GL context, a
font or a thread, for the same reason availableActions() takes plain data.
Retiring the panel found one real thing: evaluation_tsumego.scn's
`expect eval_board_text ""` had been passing because the readout was never drawn
at all. With it always live the assertion needs a frame, so it becomes
wait_until — the idiom already documented for keys reporting what was drawn.
Two questions are left open and are recorded as such in the ADR: the mark no
longer says *which* engine is thinking (colour cannot carry it, since eyeInk()
flattens hue to brightness under any stereo shader), and the win-rate bar has no
diegetic equivalent, so the readout is text only.
307 unit tests, 40 scenarios, 1 skip.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mark shipped completely static, and that was an over-reading of the rule it was meant to obey. "There is either something carved on the board or there is not" rejects a *fade* — an annotation at half alpha is lit rather than carved, and reads as a screen effect laid over the scene. It does not reject the mark being there and then not being there, which is the same two states the rule names, one at a time. So the blink is back, as a hard on/off: fully printed for half the period, fully absent for the other half, phase measured from the moment the mark first appears so it comes up printed and turns over in step with the count. A clock's colon is the precedent, and it is the only other thing moving on the board. This matters beyond taste. A static mark plus a count that changes once a second is very easy to read as a frozen program — which is the single failure the whole indicator exists to prevent, and the reason the banner it replaced was written in the first place. Wait::markVisible() returns a bool, and there is no alpha anywhere in WaitIndicator.h; that is the enforcement rather than a comment asking nicely. It joins displayedSecond() as the repaint gate, so a wait now costs two frames per second instead of one — still nothing against the twenty getIdleTimeout() offers. The count holds its own anchor whether the mark is showing or not, so nothing slides sideways twice a second. The default mark stays "O" after rendering all four ASCII candidates and looking at them: `*` sits at superscript height in Roboto and floats above the digits, `o` is x-height and disappears behind a stone on A1, and `O` is cap-height and aligns with the count. It is symmetrical in both axes already. All of them are one config edit away, and a real stone glyph still needs fonts.overlay pointed at a font that has one. ADR-0012 and CLAUDE.md are corrected: the rule is stated as being about opacity, with the fade-versus-blink distinction spelled out, because "no pulsing" on its own is exactly what produced the static mark. 310 unit tests, 40 scenarios, 1 skip. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…flags Reported from a real session: a "D" suggestion label sitting on a point with the grid still running under it, and elsewhere a patch of blank board with no label on it. The two halves of one annotation, each drawn without the other. They are genuinely two halves on two different flags. The glyph is built into GobanOverlay's own buffers under UPDATE_OVERLAY. The patch beneath it is a material written straight into Board::glStones by setBoardOverlay(), and that buffer reaches the GPU only under UPDATE_STONES, in GobanShader::shadeIt(). A repaint carrying just the first draws the label over an unpatched grid; one that removes a label without the second leaves its patch behind. The coupling was known and documented — "waking the renderer for a suggestion needs UPDATE_STONES too" — as a rule every caller had to remember. It caught the wait indicator I added earlier today, which asks for a bare UPDATE_OVERLAY twice a second, continuously, and precisely while an engine is thinking and the suggestions are churning. That is why the two halves visibly came apart now: the latent hazard finally had a caller firing into it at 2 Hz. So the rule stops being a rule. setBoardOverlay() and removeBoardOverlay() record that they touched the material, and GobanView::Update() asks the board (takeAnnotationDirty()) right after the overlay pass, folding UPDATE_STONES into the flags for that frame. One question, in one place, that no call site can forget — rather than an implicit contract that had one job and lost it. The flag is deliberately narrow: it is set only when the material actually changes value, so rewriting the same label on every overlay pass — which updateAnalysisOverlay() does constantly — does not force a pointless upload, and a label on an occupied point sets nothing at all, because a stone already hides the grid and no patch is laid. Five cases in tests/test_board_rules.cpp pin the mechanism rather than the symptom: set dirties, remove dirties, re-setting the same thing does not, and neither direction dirties on a point that has a stone on it. 315 unit tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two changes to what the board says while it is busy, both subtractive.
**The mark is gone.** It carried a configurable glyph beside the elapsed count,
defaulting to "O". Three versions were built and every one was removed, which is
worth writing down because each removal taught the rule:
* *pulsing* — an annotation at half alpha is lit rather than carved, and reads
as a screen effect laid over the scene instead of part of it;
* *static* — wrong the other way: a wait with nothing moving cannot be told
from a freeze, which is the single failure the indicator exists to prevent;
* *blinking* — correct, and then redundant. The seconds are already visibly
lapsing. A second moving thing carried no information and cost the quiet.
What is left is the count alone. It says "working, this long" without
introducing a symbol the board has no other use for and no way to explain, and
the rule generalises: a board annotation does not move except to change what it
says. `annotations.wait_glyph` and its syncing twin are gone; `wait_grace`
stays, because a clock that flashes up for one frame on every 13 ms GNU Go move
is the "something is broken" reading all over again.
**The margin's two ends now carry meanings**: left is an *action* the player
might take, right is *program status*. The recommended pass moves to the left and
the clock to the right, which is where an elapsed time reads naturally and where
it stops competing with the corner stone it used to sit under. They were the
other way round for no reason beyond the order they were written.
The glyph atlas machinery stays, on its own merit rather than the feature's. It
turned a silent failure loud: `fonts.overlay` is a setting, so the font drawing
the board's text is whatever the user pointed at, and a character the atlas lists
but the font lacks simply does not appear with nothing said anywhere. That check
now runs at startup for every character. `annotations.atlas_extra` is what a
richer font is fed through.
One mechanism was found and deliberately not built on, recorded in ADR-0012
because it will look attractive again: the analysis service yields while a
playing engine searches and keeps its last report frozen, so treating "yielded"
as stale would — with the shipped transparent stale ink — blank the readout
during every genmove and free the whole margin for an engine name. It works. But
the yield is *policy*, to avoid GPU contention (issue #45), not a limit of a
separate process, so a layout resting on it breaks the day that policy is
relaxed. Left-is-action / right-is-status does not rest on it.
Naming the thinking engine stays open, and the reason is now measured rather
than asserted: at roughly 0.35 grid units per character the bottom margin has
about ten characters free beside a centred readout on 19x19 and two on 9x9 —
and 9x9 is where the slow engine usually is.
312 unit tests, 40 scenarios, 1 skip.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported: of the suggested moves, the score readout and the elapsed clock, the clock could not be hidden at all and the other two only together — turning off "Evaluation" was the only way to lose the numbers, and it took the suggestions with it. They are now three toggles, all sticky: * toggle_evaluation_moves — the lettered suggestions on the grid * toggle_evaluation_readout — the win rate and score estimate in the margin * toggle_wait_clock — the elapsed seconds while the program is busy with `toggle_evaluation` left as what it always was: whether the analysis engine runs at all. Switching the readout off leaves the engine running, which is the point — it is a display choice, not an off switch wearing a different hat, and the scenario asserts exactly that. The readout's toggle carries the same availability as the suggestions (`actions().evaluation`: an analysis engine exists, and this is not a tsumego). The clock's deliberately does not. It reports on the *program*, not on the analysis, and it is the only thing on screen during a genmove or a resync when the evaluation is switched off — so gating it on the analysis would hide it in the case it exists for. It defaults on for the same reason: a silent wait reads as a frozen program, which is the bug report that produced all of this. This is not a reversal of ADR-0012. What that removed was a *placement* setting — a choice between an RmlUi panel and the board drawing the same fact, where keeping both meant shipping the worse one as the default. Whether a part of the one on-board display is drawn is a different question, and the ADR now says so rather than leaving the next reader to work out why one toggle came back. Menu entries in all five languages, under View → Overlay beside Best Moves and Coordinates. The Czech is mine; ja/ko/zh are machine translations, consistent with what CREDITS.md already records about the newer strings. 312 unit tests, 40 scenarios, 1 skip. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first Windows build in six months got the application through cleanly — the
NOMINMAX and subsystem fixes did their job — and then failed on the *test*
binary, which MSVC had never compiled before. The suite was written in August;
build.yml last ran in February.
Two portability bugs, both in tests:
* test_main.cpp installs the SIGPIPE disposition main.cpp installs, for the
same reason. SIGPIPE does not exist on Windows, where writing to a broken
pipe fails as an ordinary error rather than by signal, so there is nothing
to disarm. Guarded with #ifndef _WIN32, the same way main.cpp guards its
copy — which is why the application built and the tests did not.
* test_messagelog.cpp reads a constexpr inside a lambda with no default
capture. GCC and Clang treat that as no capture at all; MSVC requires it
named. Captured explicitly.
Also `fail-fast: false` on the Windows matrix. x86 has never once completed a
run: x64 failed first every time and cancelled it, so the 32-bit build has been
untested for the whole cycle while looking merely unlucky. The two architectures
are independent artifacts and a failure in one says nothing about the other.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… app
Reported: launch goban, switch Hyprland workspaces, and after several seconds
the compositor offers "Terminate or Wait" — while the application is in fact
fine, and carries on normally once dismissed.
It is fine. It is parked in eglSwapBuffers.
Under Wayland a surface that is not being presented receives no frame callbacks,
and with vsync on the swap waits for one, unbounded. The main thread therefore
stops reading the Wayland socket, xdg_wm_base.ping goes unanswered, and the
compositor concludes the client is dead. From a WAYLAND_DEBUG capture of the
reported reproduction:
3115402.426 wl_keyboard.leave focus lost, workspace switched away
3115664.629 wl_buffer.release last activity
---- 12.1 s of silence, 8 pings queued ----
3127794.7 wl_callback.done frame callback finally arrives
3127794.835 8 pings delivered in a 36 microsecond burst, all answered
3127794.878 wl_keyboard.enter focus back
Ten pings, ten pongs: every one answered, none of them in time.
**Focus is the only signal available.** Hyprland sends no wl_surface.leave and
no xdg_toplevel.configure when a window stops being shown — the only configure
in the capture arrives *after* the gap, on switching back — so neither GLFW nor
we can ask whether the surface is visible. The compositor simply stops sending
frame callbacks. wl_keyboard.leave is the one thing that does arrive, and it
arrives 262 ms before the block: enough.
So the swap interval follows focus. Unfocused is not the same as hidden, so this
costs vsync on a window that is merely in the background — tearing on frames
this event-driven loop only produces when something actually changed, on a
window nobody is looking at.
Verified by re-running the reproduction with the fix: focus loss is seen, and
the longest silence becomes 1500 ms — Hyprland's own ping interval, each one
answered — against 12130 ms before.
Two things worth recording about why this survived so long. It needs something
to be *repainting while hidden*, and an idle goban never enters a swap at all —
four attempts to reproduce it with an idle window came back clean, and the stack
was in poll() under glfwWaitEvents every time, which reads as a healthy program.
And the things that repaint on a timer have been accumulating: engine loading,
the intro animation, and now the wait clock at 1 Hz, which is why it went from
rare to reliable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tools/make-screenshot.sh produces the hero image and the small preview from a
pinned game, move, camera and overlay set. The previous screenshot was captured
by hand and nobody could recreate it, so every release began by hunting for a
position again; this is one command.
What it captures, and why each part was chosen:
* **13x13.** The overlay font is 0.8/N of a square, so annotations come out
about 1.5x larger here than on 19x19 while the board still reads as a real
game. This is the whole reason the shot works at preview size at all.
* **games/2026-02-07.sgf game 3 at move 47**, extracted to res/screenshot/ so
it survives — games/ is gitignored wholesale, and *.sgf is ignored
everywhere but tests/data, so the asset would otherwise have been silently
untracked and lost on the next clean checkout. .gitignore gained an
exception for exactly that reason.
* 47 stones across all four quadrants, and prisoners in **both** lids — one
empty lid looks like an oversight. A 3-stone handicap game, so the readout
shows a real opinion (B 87% B+2.7) rather than the 50% a balanced game
hovers at, which is the least interesting number a screenshot can show.
* The camera is pinned in res/screenshot/hero-user.json and passed with
--user-settings, so the shot cannot drift with the session and the real
user.json is never touched by the capture.
Two things the dry runs found, both now written into the scenario:
* **load_sgf lands at the end of the record**, not the start — position 141,
phase finished, territory shading up. The 47 forwards were walking from the
end and going nowhere. It rewinds first.
* **The analysis yields when an engine is to move.** The record is GNU Go
against KataGo, so at move 47 analysisMayRun() stands the analysis down
(ADR-0007 decision 6). The state reads eval_state=yielded with a frozen
report: numbers still composed, but stale — and stale ink ships fully
transparent, so the readout would have been invisible and not one suggestion
drawn. Both sides are set to Human, which is what reviewing a record means
anyway, and is when the evaluation is worth most.
It is deliberately not in tests/scenarios: it asserts almost nothing, it needs a
real analysis engine, and run_scenarios.sh must never pick it up.
The capture is fullscreen, so the framebuffer is the monitor's — 1920x1080 here
— and the preview is derived from it with LANCZOS, keeping the hero's aspect
rather than a fixed 16:9 so the board is never stretched. The annotations are
25% alpha on wood and a box filter loses them outright.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Spotted in the release screenshot: the bowls were empty at a position with two black and one white stone captured. Not a quirk of that position — the bowls have never held a prisoner in any game the program has rendered, and the prisoner labels have read 0 for the whole of every game. `GameState::capturedBlack` and `capturedWhite` were initialised to zero in the constructor and **never assigned anywhere in the program**. Both displays read them: syncPrisonerLabels() for the four labels, and GobanShader for iBlackCapturedCount / iWhiteCapturedCount, which is the count deciding how many stones the bowls contain. Board::capturedCount() had the right number the whole time, which is why every assertion on `captured_black` passed and why nothing in the suite could see this. The earlier fix to these labels — "a quantity displayed in two places is written by one function", which corrected a swapped pairing — was arranging two permanently-zero numbers. The fields are deleted. GameSnapshot carries the counts instead, published from the Board, which is also what makes them safe to read every frame while the game thread rebuilds that Board under the UI. On where they belong, since the question is fair: the *count* is model data and now lives in the snapshot. What stays on the view is GobanView::capturedBlackShown — the record of what this view last handed the shader. That is not a second copy of the truth, it is the per-view dirty check the original design intended, so a second view of the same model keeps its own and OnUpdate() still does nothing when nothing has changed. The bug was that one field was serving as both the model's count and the view's shadow, and only the shadow half was ever written. There was a second half. The repaint requested when the counts change was a bare requestRepaint() — UPDATE_SOME — while those uniforms are uploaded only under UPDATE_STONES. So even with a live count the view would have copied it and never got it to the GPU: the same shape as the annotation patch that travelled on one flag while its glyph travelled on another, fixed earlier today. Pinned by board_click_stone_in_hand.scn through a new `prisoners_drawn_white`, which reports what the *renderer* was handed rather than what the board knows — the distinction sounds_played and overlay_glyphs exist for, and the only kind of assertion that could have caught this. The screenshot is regenerated with prisoners visible, and with the recorded next-move marker switched off: "48" and the engine's A-E are two different kinds of "play here" and reading them on one board is confusing. `last_move_shown` and `next_move_shown` join dumpState so the shot asserts that rather than trusting an unpersisted default. 312 unit tests, 40 scenarios, 1 skip. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
res/screenshot/detail.png, 880x530, cut from the hero with no resampling — the point is to show the annotations at the size they are actually drawn. 880 because GitHub's .markdown-body is max-width 980 with 45px padding, leaving about 890 usable, and images get max-width:100% — anything wider is scaled down and stops being 1:1. The band holds all five suggestions and the readout, so it shows the whole green-amber-red ramp. Its coordinates follow the camera pinned in hero-user.json, so a capture of a different size is skipped rather than cropped blindly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The script ran goban unconditionally, so changing a preview width meant taking the screen fullscreen for a minute to regenerate images that are only resamples of hero.png — which is already on disk. `--derive` re-cuts the preview and the detail crop from that file instead. The preview default moves 640 -> 880, matching the detail crop and GitHub's usable column: the annotations then render at 46% of native instead of 33%, for free. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tools/package.py assembles the folder people actually download: the binary, the
whole config tree, the docs, and empty games/ and engine/ directories, archived
as tar.gz for unix and zip for Windows — a zip has nowhere to record the
executable bit, so a Linux user unpacking one gets a file they cannot run.
CI runs it *without* engines and publishes that; the same script takes
--with-engine engine/gnugo to build the hraj.si bundle. One implementation, two
outputs, so the bundle people download stops being a hand-assembled thing that
drifts from the one that is tested.
It checks itself: every ./config/... path the shipped configs name must exist in
the bundle. That is not decoration — it immediately found NotoSans-Regular.ttf,
referenced by all five language configs while being neither tracked in git nor
downloaded by CMake. It existed only on one machine, so any bundle built from a
clean checkout would have shipped a configuration pointing at a font that was
not there, with nothing failing loudly. CMake now fetches it alongside the CJK
font.
Also in this release plumbing:
* VERSION 0.1.2 -> 0.2.0, and `--version` on the command line, so a bug report
can say which build it came from without opening a dialog.
* The release job refuses to publish when the tag and PROJECT_VERSION disagree.
They are set in two places by two hands, and a binary that reports a
different number than its own tag cannot be debugged from a bug report.
* The Linux job runs ctest before packaging. A release that fails its own tests
should not be published.
* KataGo is off in the shipped configuration. The bundle carries GNU Go and
nothing else, and an engine that is configured but not installed greets a new
user with a red badge the first time they open the program.
* NotoSansSC-Regular.otf is excluded: 8.1 MB that no config references.
THIRD-PARTY.md lists what Goban is built from, with the licences read off the
dependencies rather than remembered, and states the GPL position for a bundle
that includes GNU Go.
Verified by unpacking the archive and running the application out of it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Windows bundles on hraj.si contain GNU Go built here, so GPLv3 obliges us to offer the corresponding source. That is not upstream's tarball: the tree was diffed against it and one file differs. utils/gg_utils.c declares verifyW32() twice with an implicit int return type, which C99 removed and current mingw-w64 rejects, so 3.8 will not cross-compile for Windows without the fix. Four lines, now in deps/_patches/gnugo-3.8-implicit-int.patch with the tarball URL and its sha256, so "upstream plus this patch" is a complete and checkable answer. Everything else is clean: 2256 files identical, nothing added or removed. The extra .c files under patterns/ are generated by GNU Go's own mkpat. deps/ was ignored wholesale, so the patch would have been untracked — the existing patches survive only by predating that rule. Narrowed to deps/* with deps/_patches/ excepted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closed
zip cannot store an empty directory, so games/ was in the tar.gz bundles and absent from the zips — the folder existed on Linux and macOS and not on Windows. Not fatal, since the application creates it when it first saves, but an inconsistency between platforms for no reason. It now carries a README, like engine/ does, which fixes the zip problem and says where game records go. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tools/release-hrajsi.sh takes the Windows binaries CI already built, folds in the
cross-compiled GNU Go, and writes goban_<date>_v<version>_win-{x64,x86}.zip into
the site repository. It prints the pug lines to add and the deploy command; it
does not upload, because `make goban` rsyncs the live site and that should be a
deliberate act.
Each architecture gets its matching engine rather than 32-bit for both, since
both are built here anyway.
package.py grew `--with-engine NAME=PATH` for it. The binaries are named
gnugo-x64.exe and gnugo-x86.exe while config/base.json says `"command": "gnugo"`,
so the file has to arrive as engine/gnugo/gnugo.exe — a rename that is easy to
forget and silently produces a bundle whose engine cannot start. The existing
asset check then verifies it: with an engine folded in, every enabled bot's path
must exist in the bundle.
It repackages rather than injecting into the artifact zip, so the config tree and
the binary come from the same revision — run it on the release tag.
Verified against the dispatch build: both zips built, each with
engine/gnugo/gnugo.exe inside, 20.1 MB and 19.4 MB.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t of git
Every image on hraj.si dated from 2020-10-16, except the one the page still
labelled "upcoming version", which was from 2021. Two were worse than merely
old: red-carpet-goban-0 and -9 show the pre-RmlUi menu bar and right-hand info
panel, neither of which has existed for years, so they advertise a program
nobody can download. Nothing on the page showed anything from this release.
Five shots, each a scenario plus a settings file pinning the camera, the shader
and the overlays, so a release regenerates the set instead of inheriting a
snapshot that goes stale in turn:
hero 13x13 mid-game with the evaluation on the board
territory a counted 19x19 ending — shading, result, four full bowls
closeup stones on wood; the one old shot worth keeping, because it is about
the rendering rather than a feature and so cannot date
shader Minimal Thin, because the board has more than one look
stereo the anaglyph shader, which needs a caption saying so
Plus the 1:1 detail crop derived from the hero, and thumbnails for the gallery.
**The generated PNGs are not tracked.** The sources are — the scenarios, the
settings files and the two SGFs — and they are a few kilobytes of text.
Tracking the output would add roughly 9 MB of new blobs per release to a 16 MB
repository, for files nothing here reads: they belong on hraj.si, which
tools/release-hrajsi.sh copies them to. res/screenshot.png is deliberately
outside that rule, being the one image GitHub serves for the README.
Two things the captures taught:
* The anaglyph mode is pinned in the settings file rather than set with the
`anaglyph` command — the command reports what it did into #lblMessage, and
that caption then sits across the middle of the picture.
* `gray` is used rather than half-colour: it is the shipped default, chosen
because it is the only mode that cannot be wrong about which eye owns green,
and a screenshot should show what the default produces.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tools/release-gallery.sh uploads the screenshot set to a published release and embeds it in the body, so the release page shows what that version looks like. Release assets live outside git history, which is the point: the images are regenerated every release, and tracking them would add megabytes of blobs to a 16 MB repository for files nothing in it reads. They are gitignored — so CI cannot attach them either, having no display, no engines and no GPU to render with. This runs after the tag, from a machine that does. Thumbnails are embedded inline, each linking to the full image. Embedding the full ones would make the release page several megabytes. It refuses when the release does not exist yet, when the images have not been generated, and when a gallery is already there, so re-running it is safe. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported from a Windows bundle test: in a bot-versus-bot match it was impossible to quit. The confirmation appeared and vanished again the instant GNU Go played, which it does in milliseconds, so there was no way out but to kill the process. clearMessage() does not hide a prompt, it *cancels* one — pendingPromptCallback was dropped along with the text. And the tail of ElementGame::OnUpdate() calls it on every position change that carries no comment. So each move destroyed the question, and even clicking where the prompt had been did nothing, because the callback was already gone. showMessage() has had `if (hasActivePrompt()) return;` all along. Its opposite number did not: the guard was put on one of a pair and not the other, which is the same shape as the prisoner labels and the annotation patch earlier today. Guarded inside clearMessage() rather than at its five call sites, because the two that mattered are the ones nobody thought about. The deliberate path is unaffected — handlePromptResponse() takes the callback and nulls it before clearing — and a prompt cannot be orphaned, since while one is up clicks and keys are routed to it. The last block of discard_prompts.scn pins it, and had to put an engine on the board to do so. Every earlier case in that file states its own blind spot in a comment: "both players are human and every move is a pass, so nothing advances on its own" — which is exactly the condition under which this is invisible. 312 unit tests, 40 scenarios, 1 skip. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measured at ~15 s on a Windows/NVIDIA box and 5.8 s on Intel/Mesa here, against 0.7 s once the driver has cached it. The window is unresponsive throughout and says nothing, so it reads as a hang on a machine seeing it for the first time. Documented rather than fixed for 0.2.0: the compile happens in the GobanView constructor, which RmlUi runs while loading the document — before the main loop exists and before any frame is drawn — so showing a message first means moving it out of a constructor into a lazy first-Update path, in the startup sequence. That is not a change to make on the eve of a tag against binaries already verified on four platforms. Reproducible on this machine with MESA_SHADER_CACHE_DISABLE=true, which is what makes a proper fix — compiling on a shared context in a worker thread — testable afterwards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every run warned that checkout@v4, upload-artifact@v4 and setup-msbuild@v2 target Node 20 and are being forced onto Node 24, which GitHub is removing. The obvious "bump one major" is wrong for two of them: upload-artifact is still node20 at v5 and only becomes node24 at v6; download-artifact stays node20 through v6 and needs v7. That would have left the warning in place. Each version was checked by reading `using:` out of the action's own action.yml at the tag pinned. Pinned to the latest majors rather than the lowest node24 ones. Minimising the jump buys nothing here — the deprecation clock is the Node runtime, and every candidate is equally node24 — while costing another bump later. The inputs we actually use (submodules, name, path) exist unchanged in all of them, and download-artifact v8's new `skip-decompress` defaults to false, which is the v4 behaviour we rely on. **softprops/action-gh-release is gone**, replaced by `gh release create`. It is GitHub's own CLI, preinstalled on every runner, and already what tools/release-hrajsi.sh and tools/release-gallery.sh use — so the release path loses a third-party dependency, a Node deprecation surface, and an inconsistency with the local tooling, for one command. Also written down: each artifact is our archive inside GitHub's artifact zip. The double wrapping is inherent — GitHub always zips an artifact, and we cannot upload the bare folder because artifact zips do not carry the executable bit, so a Linux `goban` would arrive unrunnable. It affects only the Actions download page; a published release asset has no extra layer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
popojan
marked this pull request as ready for review
August 28, 2026 17:14
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Everything since
v0.1.0— 241 commits. Opened as a draft: the platform bundlesare being tested from CI artifacts before this is merged and tagged.
What changed
A test suite, where there was none. 312 unit tests, 41 scenario scripts, a
mock GTP engine and a scenario recorder. Nothing below would have been safe to
attempt without it, and it found most of what is listed under Fixed.
Explicit state (ADR-0002).
GamePhase/EngineSync/LoopStatereplacedfive booleans that were each written from four different files.
The UI reads a published snapshot, never the SGF tree (ADR-0006). The game
thread owns that tree and mutates it freely; three separate partial-locking holes
are closed, one of which was a reproducible crash.
One policy for what a player may do (ADR-0005). Nine commands and the toolbar
ask
availableActions()the same question, instead of six hand-rolled guardsthat had drifted apart.
Engine robustness (ADR-0001, 0008, 0009). Actions that discard the game are
deferred rather than freezing the UI; a killed engine is restarted and resynced;
engines are synced when the board changes rather than when the player moves.
Live evaluation (ADR-0007, closes #49). A separate analysis process that
follows the review cursor, shown as a win rate and score on the board's edge and
as lettered candidate moves coloured by how much they give up. Off by default.
State is drawn on the board, not in panels over it (ADR-0012). The evaluation
panel and the "engine is thinking" banner are gone; what they said now lives in
the wood margin. Annotations are carved, not lit — nothing fades or blinks.
Stereo. Anaglyph modes matched to real glasses, crosstalk cancellation,
per-eye balance, one render pass per eye, and a depth budget documented in
docs/stereo.mdand enforced by tests.Tsumego mode, an SGF file chooser and multi-game collections (closes #47),
modifier keybindings, coordinate labels, and a message panel that surfaces engine
failures instead of hiding them in the log.
Release engineering.
tools/package.pybuilds the bundle people download —CI without engines, locally with
--with-enginefor hraj.si. Version 0.2.0, a--versionflag, a CI guard that the tag matchesPROJECT_VERSION, tests runbefore publishing, and
THIRD-PARTY.md.Fixed
Among many: hangs on scoring and on exit; the application freezing when a game
was replaced while an engine was thinking; prisoners never being drawn in the
bowls in any game (the counts were read from a field nothing ever assigned);
screenshots, which had never worked; engines configured outside the application
folder failing to start (closes #55); and, on Wayland, the compositor offering to
kill the application whenever its window was not on screen.
The Windows and macOS builds had been broken for six months —
build.ymllastran in February — and are fixed:
NOMINMAX, a global/SUBSYSTEM:WINDOWSthatwas applied to every executable,
iconvon macOS, and RmlUi 6.2 → 6.3 for astale
AGL/agl.h. Windows x86 had never once completed a run, because a failurein x64 cancelled it.
Testing
312 unit tests and 40 scenarios pass (1 skipped: it needs an audio device). All
four platforms build in CI. The bundle has been unpacked and run.
Not addressed
#48is answered by Analysis mode rather than as filed. Two questions are leftopen in ADR-0012: the wait indicator does not say which engine is thinking, and
the win-rate bar has no on-board equivalent.