Skip to content

bugfix(production): Prevent cancelling production when units already produced#2399

Open
tintinhamans wants to merge 1 commit intoTheSuperHackers:mainfrom
tintinhamans:arctic/multiunit-refund
Open

bugfix(production): Prevent cancelling production when units already produced#2399
tintinhamans wants to merge 1 commit intoTheSuperHackers:mainfrom
tintinhamans:arctic/multiunit-refund

Conversation

@tintinhamans
Copy link

@tintinhamans tintinhamans commented Mar 4, 2026

This pull request updates the logic for refunding player resources when canceling unit production, addressing a bug where players could receive a refund even after some units had already been produced.

Once production->getProductionQuantityRemaining() < production->getProductionQuantity(), it is no longer possible to cancel production.

Resolves #133 (China Red Guard can be built for free when construction is cancelled in last moment)

This guard is skipped when forceCancel = TRUE is passed, which is used by:

  • cancelAndRefundAllProduction() - building destroyed/sold, full refund is intentional
  • The script-disallow path - production blocked externally, not player-initiated

Before:

mRefund_before_FFB.mp4

After :

mRefund_after_FFB.mp4

@tintinhamans tintinhamans added Bug Something is not working right, typically is user facing NoRetail This fix or change is not applicable with Retail game compatibility labels Mar 4, 2026
@greptile-apps
Copy link

greptile-apps bot commented Mar 4, 2026

Greptile Summary

This PR fixes the "China Red Guard free unit" exploit (issue #133) by adding a forceCancel parameter to cancelUnitCreate, preventing player-initiated cancellations from issuing a refund once at least one unit from a batch has already been produced. The change is applied to both Generals/ and GeneralsMD/ code paths and is guarded by #if !RETAIL_COMPATIBLE_CRC to preserve vanilla behaviour in retail-compatible builds.

Key changes:

  • cancelUnitCreate gains Bool forceCancel = FALSE; when false and some units in the batch have already been produced, the cancel is silently blocked
  • cancelAndRefundAllProduction (building sold/destroyed) now passes TRUE, so the full-refund path is unaffected ✅
  • The update() script-disallow path is modified: under !RETAIL_COMPATIBLE_CRC it only cancels if no units have been produced yet; for partially-produced batches it falls through to normal production, allowing remaining disallowed units to complete ⚠️
  • cancelAllUnitsOfType still calls cancelUnitCreate without forceCancel, meaning partially-produced batch entries for a given unit type are silently left in the queue ⚠️

Confidence Score: 3/5

  • The core exploit fix is correct, but two secondary paths (script-disallow fallthrough and cancelAllUnitsOfType) have unresolved logic issues that could allow disallowed units to be created or batches to silently not cancel.
  • The primary bugfix (player-initiated cancel exploit) is well-implemented and cancelAndRefundAllProduction is correctly updated. However, the script-disallow path now allows a unit type explicitly forbidden by script to continue producing if it's mid-batch, which is a behavior change that bypasses the script restriction. Additionally, cancelAllUnitsOfType silently fails to cancel in-progress batch entries, breaking its documented contract. Both issues exist identically in Generals and GeneralsMD.
  • Both ProductionUpdate.cpp files — specifically the script-disallow block in update() (around line 706 in Generals, 707 in GeneralsMD) and the cancelAllUnitsOfType function (line 517 / 518).

Important Files Changed

Filename Overview
Generals/Code/GameEngine/Source/GameLogic/Object/Update/ProductionUpdate.cpp Core fix for the free-unit exploit is sound (forceCancel guard in cancelUnitCreate, cancelAndRefundAllProduction correctly passes TRUE), but the script-disallow fallthrough allows partially-produced disallowed batches to complete, and cancelAllUnitsOfType silently skips in-progress batch entries.
GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Update/ProductionUpdate.cpp Mirrors the Generals changes exactly; carries the same script-disallow fallthrough issue and cancelAllUnitsOfType silent-failure concern.
Generals/Code/GameEngine/Include/GameLogic/Module/ProductionUpdate.h Correctly adds Bool forceCancel = FALSE default parameter to both the pure-virtual interface declaration and the concrete class declaration; no issues.
GeneralsMD/Code/GameEngine/Include/GameLogic/Module/ProductionUpdate.h Mirrors the Generals header change; signature update is clean and consistent.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Player clicks Cancel] --> B{forceCancel?}
    B -- TRUE\ncancelAndRefundAllProduction /\nonDie --> C[Always remove entry + full refund]
    B -- FALSE\nplayer-initiated --> D{RETAIL_COMPATIBLE_CRC?}
    D -- YES --> E[Original: refund + remove always]
    D -- NO --> F{getProductionQuantityRemaining\n< getProductionQuantity?}
    F -- YES\nsome units already produced --> G[Early return — no refund,\nno removal, cancel blocked]
    F -- NO\nnothing produced yet --> H[Full refund + remove entry]

    I[update loop — script disallows unit type] --> J{RETAIL_COMPATIBLE_CRC?}
    J -- YES --> K[Original: cancelUnitCreate\nrefund + remove always]
    J -- NO --> L{getProductionQuantityRemaining\n== getProductionQuantity?}
    L -- YES\nnothing produced yet --> M[cancelUnitCreate + return]
    L -- NO\npartial batch in progress --> N[Fall through: production\ncontinues to completion\ndespite script disallow ⚠️]
Loading

Comments Outside Diff (2)

  1. Generals/Code/GameEngine/Source/GameLogic/Object/Update/ProductionUpdate.cpp, line 706-712 (link)

    Script-disallowed units still produced for partial batches

    When allowedToBuild() returns FALSE for a non-dozer unit that is mid-batch (e.g. 1 of 4 Red Guards already produced), the new code silently skips the cancellation and falls through to m_framesUnderConstruction++ and the normal production path. As a result, the remaining 3 Red Guards will continue to be spawned on subsequent frames, even though the script has explicitly forbidden this unit type.

    The original behavior cancelled immediately (with a full refund). The new behavior leaves the partially-produced batch completing indefinitely. If the intent of the script-disable path is to stop disallowed unit creation, passing forceCancel = TRUE to the existing call would achieve that while still preserving the refund semantic for unpaid units:

    #else
        // TheSuperHackers @bugfix arcticdolphin 13/03/2026 Force-cancel script-disallowed entries regardless of partial production.
        cancelUnitCreate(production->getProductionID(), TRUE);
        return UPDATE_SLEEP_NONE;
    #endif

    If the "finish naturally" design is truly intentional, the comment should explain why the game design prefers granting disallowed units over blocking them, since this is a non-obvious departure from the original behaviour.

    The same issue is present in GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Update/ProductionUpdate.cpp at line 707–712.

  2. Generals/Code/GameEngine/Source/GameLogic/Object/Update/ProductionUpdate.cpp, line 517 (link)

    cancelAllUnitsOfType silently skips partially-produced batch entries

    cancelAllUnitsOfType calls cancelUnitCreate without forceCancel = TRUE. Under !RETAIL_COMPATIBLE_CRC, if a batch entry has at least one unit already produced (getProductionQuantityRemaining() < getProductionQuantity()), cancelUnitCreate returns early without removing the entry. The caller saves production->m_next into temp and advances correctly, so there is no crash — but the partially-produced entry is silently left in the queue.

    The function's documented contract is to cancel all production of a given unit type. With this change it no longer fulfils that contract for in-progress batch entries.

    Passing forceCancel = TRUE here restores the expected behaviour:

    cancelUnitCreate( production->getProductionID(), TRUE );

    The identical issue is present in GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Update/ProductionUpdate.cpp at line 518.

Prompt To Fix All With AI
This is a comment left during a code review.
Path: Generals/Code/GameEngine/Source/GameLogic/Object/Update/ProductionUpdate.cpp
Line: 706-712

Comment:
**Script-disallowed units still produced for partial batches**

When `allowedToBuild()` returns `FALSE` for a non-dozer unit that is mid-batch (e.g. 1 of 4 Red Guards already produced), the new code silently skips the cancellation and falls through to `m_framesUnderConstruction++` and the normal production path. As a result, the remaining 3 Red Guards will continue to be spawned on subsequent frames, even though the script has explicitly forbidden this unit type.

The original behavior cancelled immediately (with a full refund). The new behavior leaves the partially-produced batch completing indefinitely. If the intent of the script-disable path is to **stop** disallowed unit creation, passing `forceCancel = TRUE` to the existing call would achieve that while still preserving the refund semantic for unpaid units:

```cpp
#else
    // TheSuperHackers @bugfix arcticdolphin 13/03/2026 Force-cancel script-disallowed entries regardless of partial production.
    cancelUnitCreate(production->getProductionID(), TRUE);
    return UPDATE_SLEEP_NONE;
#endif
```

If the "finish naturally" design is truly intentional, the comment should explain why the game design prefers granting disallowed units over blocking them, since this is a non-obvious departure from the original behaviour.

The same issue is present in `GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Update/ProductionUpdate.cpp` at line 707–712.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: Generals/Code/GameEngine/Source/GameLogic/Object/Update/ProductionUpdate.cpp
Line: 517

Comment:
**`cancelAllUnitsOfType` silently skips partially-produced batch entries**

`cancelAllUnitsOfType` calls `cancelUnitCreate` without `forceCancel = TRUE`. Under `!RETAIL_COMPATIBLE_CRC`, if a batch entry has at least one unit already produced (`getProductionQuantityRemaining() < getProductionQuantity()`), `cancelUnitCreate` returns early without removing the entry. The caller saves `production->m_next` into `temp` and advances correctly, so there is no crash — but the partially-produced entry is silently left in the queue.

The function's documented contract is to cancel **all** production of a given unit type. With this change it no longer fulfils that contract for in-progress batch entries.

Passing `forceCancel = TRUE` here restores the expected behaviour:

```cpp
cancelUnitCreate( production->getProductionID(), TRUE );
```

The identical issue is present in `GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Update/ProductionUpdate.cpp` at line 518.

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: b6a117c

@Caball009 Caball009 added Gen Relates to Generals ZH Relates to Zero Hour Minor Severity: Minor < Major < Critical < Blocker labels Mar 4, 2026
@tintinhamans tintinhamans force-pushed the arctic/multiunit-refund branch from 8adfb95 to 9f84a82 Compare March 4, 2026 18:52
@tintinhamans tintinhamans requested a review from Caball009 March 4, 2026 18:52
Caball009
Caball009 previously approved these changes Mar 4, 2026
Copy link

@Caball009 Caball009 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me.

Money *money = player->getMoney();
money->deposit( production->m_objectToProduce->calcCostToBuild( player ), TRUE, FALSE );
#else
// TheSuperHackers @bugfix arcticdolphin 04/03/2026 Do not refund if units were already produced from this entry
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean when cancelling the Red Guard after 1 guy exited, no refund is made, but only guy is given? This would be not great.

It better refunds the remaining fair value.

Or it should not be possible to cancel as soon as partial production has finished.

Copy link
Author

@tintinhamans tintinhamans Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It better refunds the remaining fair value.

I feel like this could be abused, you could get 1 Red Guard then for $150 if we simply halved refund.

Or it should not be possible to cancel as soon as partial production has finished.

Yea, that was my other proposal - noop cancelling if production->getProductionQuantityRemaining() < production->getProductionQuantity()

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like this could be abused, you could get 1 Red Guard then for $150 if we simply halved refund.

Maybe this is fine. Adds a new skill to the game? For example if the China guy just needs 1 Red Guard to capture an Oil Derrick nearby. China can need any help it can get. And it does not affect the stronger China Infantry General which is good too.

@Stubbjax thoughts?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

China can need any help it can get.

Except free units via well-timed production queue cancellations. 😛

It does feel like a rather unorthodox place to intentionally install a skill differentiator. Is a reasonable player likely to deliberately risk cancelling the whole unit for the sake of saving $150? I imagine it's more commonly a fluke that leads to confusion or surprise, which is generally better to minimise where possible. It is fairly trivial and would be incredibly rare either way so I don't feel too strongly about it in this regard.

The main reservation I have with the partial refund approach is when it comes to an indivisible BuildCost ÷ QuantityModifier. Tank General's Red Guard costs $375 - how would half of this be refunded without $1 being unaccounted for?

Preventing cancellation once the first unit has exited seems like the cleanest approach to me.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK I have updated this now to prevent cancelling entirely if production->getProductionQuantityRemaining() < production->getProductionQuantity().

AFAIK this time window is always quite short anyways so should have minimal impact.

Copy link

@xezon xezon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs fixing.

@xezon xezon added the Design Is a matter of game design label Mar 4, 2026
@tintinhamans tintinhamans force-pushed the arctic/multiunit-refund branch from 9f84a82 to 466c11e Compare March 5, 2026 09:33
@tintinhamans tintinhamans force-pushed the arctic/multiunit-refund branch from 466c11e to 3c23298 Compare March 5, 2026 09:40
@tintinhamans tintinhamans changed the title bugfix(production): Prevent refund if units were already produced bugfix(production): Prevent cancel if units were already produced Mar 5, 2026
@Caball009 Caball009 dismissed their stale review March 5, 2026 18:37

Changes since review.

@tintinhamans tintinhamans marked this pull request as draft March 5, 2026 23:46
@tintinhamans tintinhamans force-pushed the arctic/multiunit-refund branch from 3c23298 to cb37ac2 Compare March 8, 2026 12:59
@tintinhamans tintinhamans changed the title bugfix(production): Prevent cancel if units were already produced bugfix(production): Prevent cancelling production when units already produced Mar 8, 2026
@tintinhamans tintinhamans marked this pull request as ready for review March 8, 2026 13:04
@tintinhamans
Copy link
Author

@greptileai You mention cancelAllUnitsOfType() as a potential issue but AFAIK nothing ever calls this.

@greptile-apps
Copy link

greptile-apps bot commented Mar 8, 2026

You're right - I searched and found zero calls to cancelAllUnitsOfType(). That concern doesn't apply here since the function isn't actually used. My mistake!

@tintinhamans tintinhamans requested review from Stubbjax and xezon March 8, 2026 21:36

cancelUnitCreate(production->getProductionID());
// TheSuperHackers @info arcticdolphin 08/03/2026 Introduced forceCancel param (used to bypass partial-production refund guard)
cancelUnitCreate(production->getProductionID(), TRUE);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this force cancel very necessary? Can we not just let the partial production finish?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

forceCancel is needed for following:

cancelAndRefundAllProduction(): Called when the building is sold/destroyed. No building left to produce from - the queue must be cleared. This function loops calling cancelUnitCreate on the head entry, expecting removal each iteration. Without forceCancel, a partially-produced entry would never be removed, and the loop could spin 100 times on the same stuck entry.

Script-disable path in update(): if a script disallows a unit type mid-production, without forceCancel the entry stays in the queue, and the next update() frame hits the same disallow condition again.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only in these cases they do get full refund - I don't know how else you would want to handle this but if you have another idea let me know.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I think I understand what you mean.. I guess we can allow it to finish for the script case.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can make it work consistently? Nothing is able to force partial refunds. This avoids unexpected edge cases or behaviors that would need testing otherwise.

If some loop gets stuck, then maybe write the loop in a way it does not get stuck with a production that can no longer be cancelled? Or will this cause other problems?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So in case of building being destroyed during partial production, no refund?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If that keeps the code simpler I would say just let the pending production die alongside the building.

Do we have other opinions?

@tintinhamans tintinhamans force-pushed the arctic/multiunit-refund branch 2 times, most recently from 3dc5df1 to 43af703 Compare March 13, 2026 15:03
@tintinhamans tintinhamans force-pushed the arctic/multiunit-refund branch from 43af703 to 9c7ab57 Compare March 13, 2026 15:07
@tintinhamans tintinhamans requested a review from xezon March 13, 2026 15:09
…produced

Signed-off-by: tintinhamans <5984296+tintinhamans@users.noreply.github.com>
@tintinhamans tintinhamans force-pushed the arctic/multiunit-refund branch from 9c7ab57 to b6a117c Compare March 13, 2026 15:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Bug Something is not working right, typically is user facing Design Is a matter of game design Gen Relates to Generals Minor Severity: Minor < Major < Critical < Blocker NoRetail This fix or change is not applicable with Retail game compatibility ZH Relates to Zero Hour

Projects

None yet

Development

Successfully merging this pull request may close these issues.

China Red Guard can be built for free when construction is cancelled in last moment

4 participants