Skip to content

CRASH! [main/2026-10-15-1610] unknown #1643

Open

Description

@lyra-the-bot

Your game just encountered a critical error! :(

System Details:

  • Date-Time: 21-10-2026 12:01:54
  • Game Version: main
  • Build Date: 2026-10-15-1610
  • Commit Hash: cc651b9

Save Details:

  • Chapter Name: None
  • Current Turn: ???
  • Game Seed: 0

Error Details:

Unbalanced surface stack. You MUST use surface_reset_target() for each set. {1}

Stacktrace:

Reporter:

Papa Guilliman

User Message:

N/A

Activity

  1. added theissue type on Oct 21, 2026
  2. lyra-cactus commented on Oct 21, 2026

    @lyra-cactus
    Contributor

    Investigation Report: Issue #1643

    Title: CRASH! [main/2026-10-15-1610] unknown
    Error: Unbalanced surface stack. You MUST use surface_reset_target() for each set. {1}
    Crash context: 21-10-2026 12:01:54, Chapter Name: None, Current Turn: ???, Game Seed: 0
    Build commit: cc651b9a6 (close to current main; the offending code is identical in the analysed tree)


    1. Summary

    The game hard-crashed with the GameMaker engine's fatal "Unbalanced surface stack" error. This error is raised by the runtime when a surface was pushed onto the render-target stack with surface_set_target() but never popped with surface_reset_target() when the frame's drawing is finalised.

    The audit of the codebase shows every surface_set_target() call site is balanced on its normal (happy) execution path. The imbalance is created by the error-handling paths: GML has no finally, and a large part of the unit-rendering pipeline (the chapter-creation "livery preview") draws with render targets set while running nested inside the caller's try/catch blocks. When any exception is thrown between surface_set_target() and its matching surface_reset_target(), the exception unwinds the call stack while leaving the render target set. The outer catch blocks do not restore the target stack, so the pending target survives to the end of the frame (and even across room_goto()), at which point the engine detects the leaked target and throws the fatal exception, which is caught by the game's exception_unhandled_handler and terminates the game.

    The crash snapshot (Chapter "None", seed 0, no obj_controller) places the crash in the chapter-creation/main-menu phase, which is exactly the only phase of the game that regenerates marine sprites via the vulnerable pipeline (scr_draw_unit_image -> ComplexSet).

    The {1} in the message is the number of render targets still on the pending stack when the engine detected the imbalance - a single leaked surface_set_target().


    2. Background: the engine error

    The report was produced by the project's uncaught-exception hook:


    3. Where the crash happened: reconstruction from the save details

    Field Value Interpretation
    Chapter Name None The boot default, untouched by chapter creation yet: https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/globals/globals.gml#L157. global.chapter_name is only assigned at the end of creation: https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/scr_initialize_custom/scr_initialize_custom.gml#L768
    Game Seed 0 Boot default: https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/globals/globals.gml#L158. Only finalised at creation end: https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/scr_initialize_custom/scr_initialize_custom.gml#L771-L772
    Current Turn ??? ErrorHandler.get_context() returns ??? when obj_controller does not exist: https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/ErrorHandler/ErrorHandler.gml#L182-L194

    Conclusion: the fatal check fired before chapter creation completed - i.e. at the main menu, boot, or (by far the likeliest given the nature of the error) the chapter-creation screen, where the "livery picker" preview actively renders marines into surfaces every time its sprite needs regeneration.

    The creation-screen draw chain that owns surfaces:

    1. obj_creation Draw event, whole body wrapped in try from line 3, catch at 849-852: https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/objects/obj_creation/Draw_0.gml#L3 and https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/objects/obj_creation/Draw_0.gml#L849-L852
    2. obj_creation/Create_0.gml#L56 creates livery_picker = new ColourItem(100, 230).
    3. ColourItem.draw_base -> data_slate callback draws the dummy marine when the sprite needs (re-)generation: https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/ColourItem/ColourItem.gml#L742-L747 (dummy_image = dummy_marine.draw_unit_image()).
    4. DummyMarine binds the shared drawing script: https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/scr_culture_visuals/scr_culture_visuals.gml#L2180-L2200 (static draw_unit_image = scr_draw_unit_image;)
    5. scr_draw_unit_image recursively uses ComplexSet for complex liveries: https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/scr_draw_unit_image/scr_draw_unit_image.gml#L346-L349

    4. Surface usage audit

    All 13 surface_set_target() call sites were reviewed:

    Site Pairing Exception safety
    scr_draw_unit_image.gml#L37-L42 surface_clear_and_free set/clear/reset/free Safe (self-contained)
    scr_draw_unit_image.gml#L213-L480 main marine render set L213 / reset L480 Not exception-safe - see 4.1
    ColourPicker.gml#L74-L88 create_texture_surface set L74 / reset L88 Not exception-safe - see 4.4
    ColourPicker.gml#L45 static textures_surface - N/A
    scr_ComplexSet.gml#L1185-L1234 draw_component_with_textures get/reset/set vs/ own base surface Not exception-safe (same class)
    scr_ComplexSet.gml#L1539-L1735 ComplexSet.draw prep surface set L1539 / reset L1735 + restore L1743 Broken early-exit path at L1741 - see 4.3
    scr_ComplexSet.gml#L2192-L2219 complex_helms set L2192 / reset L2219 Not exception-safe at depth 2 - see 4.2
    scr_ComplexSet.gml#L225 global.base_component_surface re-create - N/A
    scr_ui_manage.gml#L481-L484 screenshot capture set/reset Safe
    scr_sprite_functions.gml#L65-L71 return_sprite_mirrored set/reset per frame Safe
    scr_culture_visuals.gml#L74-L77 load_new_icon set/reset Safe (runs at boot, outside draw)
    scr_scrollbar.gml#L153-L176 ScrollableContainer start/stop set L159 / reset L176 across two methods Fragile splitting - see 4.5
    obj_star/Draw_0.gml#L67-L88 star tag sprite set/reset Safe, but only exists in-game (not this crash)

    4.1 scr_draw_unit_image: the "one reset for N potentially pending targets" defect

    The function sets one target and resets it exactly once:

    The contract "one set -> one reset" only holds if execution reaches line 480 normally. It does not hold when an exception escapes while more than one target is pending, or when an exception escapes from the catch/post-catch region:

    1. Nested targets pending at depth > 1. Inside the try, the drawing recurses into ComplexSet.draw() and friends, which manipulate the target stack themselves (see 4.2/4.3). If an exception is thrown while the stack holds 2+ targets, unwinding propagates the exception to this function's catch (https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/scr_draw_unit_image/scr_draw_unit_image.gml#L446-L448), which pops only the ONE target at line 480. The deeper targets remain on the stack.
    2. Unwrapped code between catch and reset (L450-L479). The follow-up drawing block (name_role(), race(), string_count(), draw_sprite of role models) is NOT inside a try: https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/scr_draw_unit_image/scr_draw_unit_image.gml#L450-L479. name_role()/race() are polymorphic methods bound to whichever struct drives the render (DummyMarine in creation, marine structs in game). Any exception there leaves the unit surface target permanently set (a leftover of exactly 1) and propagates to the enclosing event's catch.

    Note that even the balanced-looking exception path has a second-order defect: when an exception is caught at line 446 after the ComplexSet.draw() prelude swapped targets (L1536-L1539), line 480 pops a different surface than unit_surface - the stack count is balanced but the wrong target was popped, unit_surface remains black, and the pending composition state differs from the code's assumption.

    4.2 complex_helms: a pending second target inside the drawing recursion

    https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/scr_ComplexSet/scr_ComplexSet.gml#L2176-L2227

            pattern == 1 || data.helm_pattern == 3) {
                var _surface_width = sprite_get_width(_head_resolved.sprite);
                var _surface_height = sprite_get_height(_head_resolved.sprite);
                var _head_surface = surface_create(_surface_width, 60);
                surface_set_target(_head_surface);     // L2192 - stack: [prep_surface, _head_surface]
                ...
                draw_head({"head_stripe": {...}});      // L2208 - shader uniforms, sprite draws
                ...
                surface_reset_target();                 // L2219

    complex_helms is called from ComplexSet.draw() at https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/scr_ComplexSet/scr_ComplexSet.gml#L1647-L1648, while prep_surface is already the pending target. Any exception between L2192 and L2219 (set_complex_shader_area, uniform setup on _obj.col_r[data.helm_secondary], the draw_head chain, remove_area) unwinds with two pending targets, is caught one level up at https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/scr_draw_unit_image/scr_draw_unit_image.gml#L446, and the single reset at L480 leaves exactly one pending target - matching the reported {1}.

    4.3 ComplexSet.draw(): the early exit that leaves the caller's stack state broken

    https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/scr_ComplexSet/scr_ComplexSet.gml#L1734-L1747

            shader_reset();
            surface_reset_target();                 // L1735 pops prep_surface; stack empty
            if (!surface_exists(prep_surface) || !surface_exists(_final_surface)) {
                draw_sprite(spr_none, 0, 0, 0);
                if (surface_exists(prep_surface)) {
                    surface_clear_and_free(prep_surface);
                }
                exit;                               // L1741 returns WITHOUT restoring _final_surface
            }
            surface_set_target(_final_surface);     // L1743 normal path restores caller's target

    If prep_surface or _final_surface were lost (e.g. GPU/device loss after alt-tab, driver reset, window mode change - note the game resizes application_surface on any window resize in https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/objects/obj_persistent/Step_0.gml#L6-L19 -> https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/SettingsManager/SettingsManager.gml#L81-L89), the function exits with an empty stack, while the caller (scr_draw_unit_image) will later execute its unconditional surface_reset_target() at line 480 on an empty stack. Per the manual, calling surface_reset_target() when no target was previously set throws a fatal error (https://manual.gamemaker.io/monthly/en/GameMaker_Language/GML_Reference/Drawing/Surfaces/surface_reset_target.htm) - the inverse imbalance of the same stack discipline.

    4.4 ColourPicker.create_texture_surface: single leftover on exception

    https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/ColourPicker/ColourPicker.gml#L56-L92

    surface_set_target(textures_surface) at L74 and surface_reset_target() at L88 wrap a loop that resolves sprites from a data set (texture_set[$ texture_names[i]] at L81) and draws them (draw_sprite_part_ext at L82). This is driven from the creation-screen popups (obj_creation_popup/Draw_0.gml -> assign_complex_picked_liveries -> picker.draw(), https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/objects/obj_creation_popup/Draw_0.gml#L1-L26, and ColourItem.draw_base -> colour_pick.draw(), https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/ColourItem/ColourItem.gml#L672-L674) as well as the in-creation "Texture" toggle: https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/ColourItem/ColourItem.gml#L745-L747. Both enclosing draw events have only generic catch blocks that do not restore the target. An invalid/failed sprite in a user-supplied visual set (main/visual_sets, loaded dynamically in load_vis_set_to_global: https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/scr_culture_visuals/scr_culture_visuals.gml#L103-L126) makes the draw throw and leaves textures_surface pending - another leftover of exactly 1.

    4.5 ScrollableContainer: set and reset split across two methods

    https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/scr_scrollbar/scr_scrollbar.gml#L153-L177 with the only caller pair at https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/scr_creation_draw_slides/scr_creation_draw_slides.gml#L623 and https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/scr_creation_draw_slides/scr_creation_draw_slides.gml#L661 (chapter icon popup during creation).

    Between set and reset, the icon loop dereferences global.chapter_icons_map[? _icon_name] (https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/scr_creation_draw_slides/scr_creation_draw_slides.gml#L632) - an unchecked ds_map lookup whose result is passed straight to draw_sprite_stretched. If the name array and the map ever diverge, the draw throws and the container's surface stays pending (caught by obj_creation Draw's catch at L849 -> leftover 1 -> {1} on the next frame end).

    4.6 What happens after a leftover is produced

    The outer draw-event catch blocks do not restore the render-target stack:

    A pending target is engine-global state; it survives both the instance_destroy() and the room_goto(). On the next frame's draw phase, all remaining drawing silently renders into the leaked surface, and when the draw phase completes the runtime raises:

    Unbalanced surface stack. You MUST use surface_reset_target() for each set. {1}
    

    with {1} = the number of leaked pending targets. The exception is engine-raised (no GML stack), so _exception.stacktrace is empty -> the report title falls back to "unknown" (CRASH! [main/2026-10-15-1610] unknown), and everything downstream dies through exception_unhandled_handler (https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/scr_logging_functions/scr_logging_functions.gml#L1-L4). This reproduces every symptom of issue #1643, including the empty Stacktrace section.


    5. Root cause

    Primary root cause: GML's surface stack has no scope/RAII semantics and the project's try/catch blocks do not restore render targets when an exception unwinds code that runs between surface_set_target() and surface_reset_target(). Any handled error in the deep marine-rendering pipeline (creation livery preview: scr_draw_unit_image -> ComplexSet -> complex_helms, plus ColourPicker/ScrollableContainer flows) leaves a pending render target, which the engine detects at the end of a draw phase and turns into a fatal, unrecoverable crash.

    Contributing structural defects, in order of likelihood for this crash ({1} = one leaked target):

    1. complex_helms sets a second target while ComplexSet.draw() already owns one; the outer catch chain pops only one level (section 4.2).
    2. scr_draw_unit_image leaves its target set over an un-try-protected code block (L450-L479) and inside a catch whose own reporting path is not stack-safe (section 4.1).
    3. ColourPicker.create_texture_surface / ScrollableContainer set-target scopes integrated into catch-all-drawing-event code paths, where the catch discards the stack state via room_goto()/instance_destroy() (sections 4.4/4.5).
    4. ComplexSet.draw() early-exit pops the caller's target and never pushes it back (section 4.3).

    6. Proposed fixes

    Fix A (core): exception-safe target ownership in scr_draw_unit_image

    GML has no finally, so restore the target by comparing against a snapshot. Replace the single surface_reset_target() with a "restore to entry state" loop, and move the currently unprotected block (L450-L479) inside the try:

    function scr_draw_unit_image(_background = false) {
        ...
        var unit_surface = surface_create(600, 600);
        var _entry_target = surface_get_target();
        surface_set_target(unit_surface);
        draw_clear_alpha(c_black, 0);
        draw_set_font(fnt_40k_14b);
        draw_set_color(c_gray);
        ...
        try {
            // entire draw body, including the currently unprotected L450-L479 block
        } catch (_exception) {
            ERROR_HANDLER.handle_exception(_exception);
        }
    
        draw_set_alpha(1);
    
        // Restore the stack to its entry state: pops the function's own target on the
        // normal path, and any targets left pending by an aborted nested draw chain.
        while (surface_get_target() != _entry_target) {
            surface_reset_target();
        }
        shader_reset();
        ...
    }

    This single change handles:

    • the normal path (pops exactly the one own target),
    • exceptions at nested depth (pops all pending extras, not just one),
    • the ComplexSet.draw() early-exit case (recognition that the stack got back to the snapshot state avoids the fatal reset-on-empty-stack),
    • exceptions in the previously unprotected block (L450-L479).

    Because surface_get_target() returns application_surface when the stack is empty and the app surface is enabled (and -1 when the app surface itself is disabled), snapshotting surface_get_target() at function entry and comparing to it is consistent in both cases; no engine-native depth query exists (see note in "Verification" below).

    Fix B: infrastructure helper and application to the remaining paired sites

    Add a tiny helper next to surface_clear_and_free (https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/scr_draw_unit_image/scr_draw_unit_image.gml#L37-L42):

    /// @param {Surface} _entry_target The value surface_get_target() returned before the scope's own set_target
    function surface_target_restore(_entry_target) {
        while (surface_get_target() != _entry_target) {
            surface_reset_target();
        }
    }

    Apply the snapshot/restore pattern (var _saved = surface_get_target(); surface_set_target(...); try { ... } catch(...) { ERROR_HANDLER.handle_exception(...); surface_target_restore(_saved); } surface_target_restore(_saved);) to the remaining scopes that can execute ---- or be unwound by ---- caught exceptions while holding a target:

    Fix C (systemic): restore the target stack in draw-event catch blocks

    The long-standing convention that draw-event bodies are wrapped in catch-all try blocks (obj_creation, obj_creation_popup, obj_controller Draw_0/Draw_64, obj_managment_panel, obj_fleet_select, obj_drop_select, obj_star_select, obj_popup, ...) means a caught error can abort one instance's draw while other instances continue drawing in the same frame with a corrupted target state. For each such event:

    // top of event
    try {
        var _event_entry_target = surface_get_target();
        ... original body ...
    } catch (_exception) {
        ERROR_HANDLER.handle_exception(_exception);
        surface_target_restore(_event_entry_target);
        room_goto(rm_main_menu);   // existing recovery, unchanged
    }

    This neutralizes the crash class even when the origin is inside a pattern that has not individually been hardened (Fix B), and it complements the existing room_goto(rm_main_menu) recovery rather than replacing it.

    These are not hypothetical guards: the failure mode is a documented, player-reported hard crash (this issue), so they meet the project's "every guard must have a concrete, reachable failure mode" rule.

    Fix D (report quality, optional but cheap): better titles for engine-raised exceptions

    ErrorHandler.handle_exception derives the report title from _stacktrace[0] and prints "unknown" when the engine exception has no GML stacktrace. Fall back to the message prefix so future issues of this class are self-describing, e.g. CRASH! [main/<build>] Unbalanced surface stack...:

    Optionally record surface_get_target() (pending target) in the report so a repeat of this class is diagnosable from the issue body alone.


    7. Trigger scenarios and verification plan

    Reproduction/verification steps for the fixes:

    1. Nested-depth exception ({1} class): during chapter creation, force a data fault in the complex-livery chain (e.g. a visual_sets entry whose sprite failed to load, or a complex_livery_data field missing for the selected sergeant pattern) so complex_helms or draw_component_with_textures throws between set/reset. Confirm on main the game dies with "Unbalanced surface stack {1}"; confirm with Fix A/B it survives with the handled-error popup.
    2. Icons popup: open the chapter icon picker on a data set where a chapter_icons_array entry is missing from global.chapter_icons_map (L632) -> exception between L623/L661 -> same leftover; verify Fix C keeps the session alive.
    3. Lost-surface path: exercise ComplexSet.draw() with prep_surface/_final_surface non-existent (simulate a lost surface) -> previously the caller's reset-on-empty-stack threw; with Fix A/B the restore loop simply no-ops.
    4. Regression normal path: verify the creation livery preview, icon textures, and complex helm rendering are pixel-identical after the refactor (the restore loop replaces a single reset with an equivalent one-pop on the normal path).

    Caveat: GML exposes no direct query for the pending-target stack depth (surface_get_target_depth() returns the depth-buffer surface, not the stack depth - https://manual.gamemaker.io/monthly/en/GameMaker_Language/GML_Reference/Drawing/Surfaces/surface_get_target_depth.htm), so the surface_get_target() snapshot comparison is the framework-supported way to restore the stack state.


    8. References

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions