Fix stale subsystem model_num references across mission boundaries#7370
Draft
Goober5000 wants to merge 1 commit intoscp-fs2open:masterfrom
Draft
Fix stale subsystem model_num references across mission boundaries#7370Goober5000 wants to merge 1 commit intoscp-fs2open:masterfrom
Goober5000 wants to merge 1 commit intoscp-fs2open:masterfrom
Conversation
…cp-fs2open#3147) Fixes the long-standing intermittent bug where the GTD Orion#Repulse in Silent Threat: Reborn's "Forced Hand" mission spawns with all subsystems unlinked, leaving the ship untargetable and undisableable. The same root cause has been reported since at least 2015 (HLP topic 89403) and was provisionally closed in scp-fs2open#3147 as fixed by scp-fs2open#3858, but scp-fs2open#3858 was an unrelated parse_ship_values memory leak fix and never actually addressed this behavior. Recently the underlying check at ship.cpp ~8043 was promoted from Warning to Error (scp-fs2open#5591), which is why Release players are now hitting a hard error dialog instead of just seeing missing subsystems. Root cause ---------- model_unload() walks Ship_info and resets si.model_num = -1 on every ship_info that referenced the model, but does not touch si.subsystems[k].model_num. Subsystem entries are left pointing at the freed model id. When a subsequent mission reloads the same pof under a new id, ship variants that share the model rely on ship_copy_subsystem_fixup() to re-link their subsystem arrays, but the fixup matches by name via model_copy_subsystems() and any unmatched destination subsystem stays at -1. The mismatch case in model_copy_subsystems() was guarded only by Int3(), which is a no-op in Release builds, so the failure was completely silent until subsys_set() errored out much later with no useful context. The bug is intermittent because it depends on three independent ordering factors: which prior mission was last played, which Orion variant that mission used, and the iteration order of Ship_info during the next mission's ship_page_in(). Changes ------- * model_unload(): when clearing si.model_num for an unloaded model, also clear every si.subsystems[k].model_num that pointed at the same id. This is the structural fix and eliminates the entire class of stale subsystem pointers surviving across mission boundaries. * model_copy_subsystems(): replace the silent Int3() in the name-mismatch branch with Error(LOCATION, ...) naming the unmatched subsystem. This surfaces a failure mode that has been invisible in Release builds for over a decade and gives modders an actionable diagnostic when sibling ship classes that share a model have inconsistent subsystem names. * ship.cpp: introduce verify_ship_subsystems_linked() helper that checks every model_subsystem on a ship_info is linked into the same model the ship is using, and fails with Error(LOCATION, ...) naming both polymodels if not. This is the canonical post-condition for both ship_copy_subsystem_fixup() and model_load() (which is supposed to link subsystems via do_new_subsystem()). * ship_create(): call verify_ship_subsystems_linked() after ship_copy_subsystem_fixup() so that any failure to link a subsystem is surfaced with full context (ship name, subsystem name, both model filenames) instead of propagating to a generic error in subsys_set() much later. This closes a long-standing asymmetry where ship_page_in() had a guard (added by zookeeper in 2015) but ship_create() did not. * ship_page_in(): three post-fixup verifications were gated by #ifndef NDEBUG with Warning() or Assertion() calls, both of which are no-ops in Release builds. Replace each with a verify_ship_subsystems_linked() call so they fire in Release as well. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Baezon
approved these changes
Apr 11, 2026
Member
Baezon
left a comment
There was a problem hiding this comment.
The model_unload changes are the real smoking gun here, but the others should help any similar issues from happening as well.
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.
Fixes #3147, the long-standing intermittent bug where the GTD Orion#Repulse in Silent Threat: Reborn's "Forced Hand" mission spawns with all subsystems unlinked, leaving the ship untargetable and undisableable. The same root cause has been reported since at least 2015 (HLP topic 89403) and was provisionally closed in #3147 as fixed by #3858, but #3858 was an unrelated parse_ship_values memory leak fix and never actually addressed this behavior. Recently the underlying check at ship.cpp ~8043 was promoted from Warning to Error (#5591), which is why Release players are now hitting a hard error dialog instead of just seeing missing subsystems.
Root cause
model_unload() walks Ship_info and resets si.model_num = -1 on every ship_info that referenced the model, but does not touch si.subsystems[k].model_num. Subsystem entries are left pointing at the freed model id. When a subsequent mission reloads the same pof under a new id, ship variants that share the model rely on ship_copy_subsystem_fixup() to re-link their subsystem arrays, but the fixup matches by name via model_copy_subsystems() and any unmatched destination subsystem stays at -1. The mismatch case in model_copy_subsystems() was guarded only by Int3(), which is a no-op in Release builds, so the failure was completely silent until subsys_set() errored out much later with no useful context.
The bug is intermittent because it depends on three independent ordering factors: which prior mission was last played, which Orion variant that mission used, and the iteration order of Ship_info during the next mission's ship_page_in().
Changes
model_unload(): when clearing si.model_num for an unloaded model, also clear every si.subsystems[k].model_num that pointed at the same id. This is the structural fix and eliminates the entire class of stale subsystem pointers surviving across mission boundaries.
model_copy_subsystems(): replace the silent Int3() in the name-mismatch branch with Error(LOCATION, ...) naming the unmatched subsystem. This surfaces a failure mode that has been invisible in Release builds for over a decade and gives modders an actionable diagnostic when sibling ship classes that share a model have inconsistent subsystem names.
ship.cpp: introduce verify_ship_subsystems_linked() helper that checks every model_subsystem on a ship_info is linked into the same model the ship is using, and fails with Error(LOCATION, ...) naming both polymodels if not. This is the canonical post-condition for both ship_copy_subsystem_fixup() and model_load() (which is supposed to link subsystems via do_new_subsystem()).
ship_create(): call verify_ship_subsystems_linked() after ship_copy_subsystem_fixup() so that any failure to link a subsystem is surfaced with full context (ship name, subsystem name, both model filenames) instead of propagating to a generic error in subsys_set() much later. This closes a long-standing asymmetry where ship_page_in() had a guard (added by zookeeper in 2015) but ship_create() did not.
ship_page_in(): three post-fixup verifications were gated by #ifndef NDEBUG with Warning() or Assertion() calls, both of which are no-ops in Release builds. Replace each with a verify_ship_subsystems_linked() call so they fire in Release as well.