You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
surface_set_target() pushes a surface onto an internal render-target stack; surface_reset_target() pops it. The stack must be balanced for every set (Mike Dailly's blog post "Using Surfaces", 2018: "for each surface which is set, you must have a matching surface reset in order to maintain the stack"): https://gamemaker.io/en/blog/using-surfaces
When the runtime reaches the end of a frame's draw phase with a non-empty pending stack, it raises a fatal, engine-level exception with exactly this message. Because it is raised by the engine (not from GML code), the exception carries no useful GML stacktrace - which is why the auto-report for this issue has an empty "Stacktrace" section and the reporter derived the title line from _stacktrace[0] falling back to "unknown" (ErrorHandler.gml: https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/ErrorHandler/ErrorHandler.gml#L201-L205).
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:
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:
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.
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
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}
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):
complex_helms sets a second target while ComplexSet.draw() already owns one; the outer catch chain pops only one level (section 4.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).
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).
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
/// @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 eventtry {
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:
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.
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.
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.
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).
Activity
lyra-cactus commentedon Oct 21, 2026
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 withsurface_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 nofinally, 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'stry/catchblocks. When any exception is thrown betweensurface_set_target()and its matchingsurface_reset_target(), the exception unwinds the call stack while leaving the render target set. The outercatchblocks do not restore the target stack, so the pending target survives to the end of the frame (and even acrossroom_goto()), at which point the engine detects the leaked target and throws the fatal exception, which is caught by the game'sexception_unhandled_handlerand 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 leakedsurface_set_target().2. Background: the engine error
surface_set_target()pushes a surface onto an internal render-target stack;surface_reset_target()pops it. The stack must be balanced for every set (Mike Dailly's blog post "Using Surfaces", 2018: "for each surface which is set, you must have a matching surface reset in order to maintain the stack"): https://gamemaker.io/en/blog/using-surfaces_stacktrace[0]falling back to"unknown"(ErrorHandler.gml: https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/ErrorHandler/ErrorHandler.gml#L201-L205).The report was produced by the project's uncaught-exception hook:
exception_unhandled_handler->ERROR_HANDLER.handle_exception(_exception, true))critical = trueflag produces theCRASH!prefix in the title and the game terminates after the report dialog (handler returns 0).3. Where the crash happened: reconstruction from the save details
Noneglobal.chapter_nameis only assigned at the end of creation: https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/scr_initialize_custom/scr_initialize_custom.gml#L7680???ErrorHandler.get_context()returns???whenobj_controllerdoes not exist: https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/ErrorHandler/ErrorHandler.gml#L182-L194Conclusion: 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:
obj_creationDraw event, whole body wrapped intryfrom line 3,catchat 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-L852obj_creation/Create_0.gml#L56createslivery_picker = new ColourItem(100, 230).ColourItem.draw_base->data_slatecallback 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()).DummyMarinebinds 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;)scr_draw_unit_imagerecursively usesComplexSetfor complex liveries: https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/scr_draw_unit_image/scr_draw_unit_image.gml#L346-L3494. Surface usage audit
All 13
surface_set_target()call sites were reviewed:surface_clear_and_freecreate_texture_surfacetextures_surfacedraw_component_with_texturesComplexSet.drawprep surfacecomplex_helmsglobal.base_component_surfacere-createreturn_sprite_mirroredload_new_iconScrollableContainerstart/stop4.1
scr_draw_unit_image: the "one reset for N potentially pending targets" defectThe function sets one target and resets it exactly once:
surface_create+surface_set_target(unit_surface): https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/scr_draw_unit_image/scr_draw_unit_image.gml#L212-L213tryblock: https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/scr_draw_unit_image/scr_draw_unit_image.gml#L221-L448surface_reset_target(): https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/scr_draw_unit_image/scr_draw_unit_image.gml#L480The 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:try, the drawing recurses intoComplexSet.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'scatch(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.catchandreset(L450-L479). The follow-up drawing block (name_role(),race(),string_count(),draw_spriteof role models) is NOT inside atry: 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 (DummyMarinein 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'scatch.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 thanunit_surface- the stack count is balanced but the wrong target was popped,unit_surfaceremains black, and the pending composition state differs from the code's assumption.4.2
complex_helms: a pending second target inside the drawing recursionhttps://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/scr_ComplexSet/scr_ComplexSet.gml#L2176-L2227
complex_helmsis called fromComplexSet.draw()at https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/scr_ComplexSet/scr_ComplexSet.gml#L1647-L1648, whileprep_surfaceis already the pending target. Any exception between L2192 and L2219 (set_complex_shader_area, uniform setup on_obj.col_r[data.helm_secondary], thedraw_headchain,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 earlyexitthat leaves the caller's stack state brokenhttps://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/scr_ComplexSet/scr_ComplexSet.gml#L1734-L1747
If
prep_surfaceor_final_surfacewere lost (e.g. GPU/device loss after alt-tab, driver reset, window mode change - note the game resizesapplication_surfaceon 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 unconditionalsurface_reset_target()at line 480 on an empty stack. Per the manual, callingsurface_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 exceptionhttps://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/ColourPicker/ColourPicker.gml#L56-L92
surface_set_target(textures_surface)at L74 andsurface_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_extat 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, andColourItem.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 genericcatchblocks that do not restore the target. An invalid/failed sprite in a user-supplied visual set (main/visual_sets, loaded dynamically inload_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 leavestextures_surfacepending - another leftover of exactly 1.4.5
ScrollableContainer: set and reset split across two methodshttps://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 uncheckedds_maplookup whose result is passed straight todraw_sprite_stretched. If the name array and the map ever diverge, the draw throws and the container's surface stays pending (caught byobj_creationDraw'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
catchblocks do not restore the render-target stack:handle_exception(...)thenroom_goto(rm_main_menu).handle_exception(ex)theninstance_destroy().A pending target is engine-global state; it survives both the
instance_destroy()and theroom_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:with
{1}= the number of leaked pending targets. The exception is engine-raised (no GML stack), so_exception.stacktraceis empty -> the report title falls back to "unknown" (CRASH! [main/2026-10-15-1610] unknown), and everything downstream dies throughexception_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/catchblocks do not restore render targets when an exception unwinds code that runs betweensurface_set_target()andsurface_reset_target(). Any handled error in the deep marine-rendering pipeline (creation livery preview:scr_draw_unit_image->ComplexSet->complex_helms, plusColourPicker/ScrollableContainerflows) 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):
complex_helmssets a second target whileComplexSet.draw()already owns one; the outer catch chain pops only one level (section 4.2).scr_draw_unit_imageleaves 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).ColourPicker.create_texture_surface/ScrollableContainerset-target scopes integrated into catch-all-drawing-event code paths, where the catch discards the stack state viaroom_goto()/instance_destroy()(sections 4.4/4.5).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_imageGML has no
finally, so restore the target by comparing against a snapshot. Replace the singlesurface_reset_target()with a "restore to entry state" loop, and move the currently unprotected block (L450-L479) inside thetry:This single change handles:
ComplexSet.draw()early-exit case (recognition that the stack got back to the snapshot state avoids the fatal reset-on-empty-stack),Because
surface_get_target()returnsapplication_surfacewhen the stack is empty and the app surface is enabled (and-1when the app surface itself is disabled), snapshottingsurface_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):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:ColourPicker.create_texture_surface(https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/ColourPicker/ColourPicker.gml#L73-L88)ComplexSet.draw_component_with_textures(https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/scr_ComplexSet/scr_ComplexSet.gml#L1181-L1247) - snapshot at entry; the existing get/reset/re-set dance can be kept, just make both exit paths restore to the snapshotComplexSet.complex_helms(https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/scr_ComplexSet/scr_ComplexSet.gml#L2191-L2222)ComplexSet.draw(https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/scr_ComplexSet/scr_ComplexSet.gml#L1535-L1747) - and make theexitpath (L1741) unambiguous, e.g. by returning asurface_drawnboolean the caller respects, instead of leaving the caller to pop a possibly-empty stackScrollableContainer.start_draw_to_surface/stop_draw_to_surfacepairing (https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/scripts/scr_scrollbar/scr_scrollbar.gml#L153-L177): encapsulate the whole contents-drawing into one method that owns set and reset (e.g.draw_contents(setup_fn, draw_fn)), so the pair cannot be split by an exception or a future caller forgettingstopobj_starDraw tag-sprite block (https://github.com/Adeptus-Dominus/ChapterMaster/blob/main/objects/obj_star/Draw_0.gml#L64-L93)Fix C (systemic): restore the target stack in draw-event
catchblocksThe long-standing convention that draw-event bodies are wrapped in catch-all
tryblocks (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: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_exceptionderives 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:
visual_setsentry whose sprite failed to load, or acomplex_livery_datafield missing for the selected sergeant pattern) socomplex_helmsordraw_component_with_texturesthrows between set/reset. Confirm onmainthe game dies with "Unbalanced surface stack {1}"; confirm with Fix A/B it survives with the handled-error popup.chapter_icons_arrayentry is missing fromglobal.chapter_icons_map(L632) -> exception between L623/L661 -> same leftover; verify Fix C keeps the session alive.ComplexSet.draw()withprep_surface/_final_surfacenon-existent (simulate a lost surface) -> previously the caller's reset-on-empty-stack threw; with Fix A/B the restore loop simply no-ops.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 thesurface_get_target()snapshot comparison is the framework-supported way to restore the stack state.8. References