Skip to content

enhancement: consistent lazy loading - #11763

Open
JacopoMadaluni wants to merge 7 commits into
Card-Forge:masterfrom
witchesofthehill:enhance/consistent-lazy-loading
Open

enhancement: consistent lazy loading#11763
JacopoMadaluni wants to merge 7 commits into
Card-Forge:masterfrom
witchesofthehill:enhance/consistent-lazy-loading

Conversation

@JacopoMadaluni

@JacopoMadaluni JacopoMadaluni commented Sep 1, 2026

Copy link
Copy Markdown

The problem

A forge engine needs the full cardb in memory (about 400MB) to run a single game.
This is impractical for someone hosting a server, or in my case, for running forge in the browser via GraalVM and web workers.

The proposed fix

Re enable LOAD_CARD_SCRIPTS_LAZILY and make lazy lookups consistent with eager lookup.

Eager mode is unchanged, the following only applies when lazy mode is enabled.

At startup every card script is stream-parsed to build a {name: file} index covering all names from all scripts.
This includes CopyFaces, flavour names, double // cards. The parsed rules are discarded to keep resident memory light. Builds in about 1s and runs inside a 64MB heap in my case.
A lookup then becomes a matter of resolving a file from the name from the index. This should, as far as I understand, yield identical coverage to eager mode.

A minor util was included, resetLazyLoadedCards(), which allows the caller to drop accumulated cards. Custom cards are re-fed afterwards so they survive the reset. I wasn't sure about these so just in case, I'm never clearing them up. I don't fully understand the impact this has on custom cards.

ensureAllCardsLoaded

Theres a bunch of places in which I had to call ensureAllCardsLoaded. I don't particularly like this, but theres some nasty cards like jhoira-of-the-ghitu-avatar that as far as I understand would need to take a random sorcery from the whole card pool. That would require the whole thing to be in memory. Theres other few cases that require this, but I think they are all warranted.

Testing

Compiles against master (forge-core, forge-game, forge-gui).

$ mvn -U -B clean test
forge-gui-desktop: Tests run: 360, Failures: 0, Errors: 0, Skipped: 6
forge-game:        Tests run: 3,   Failures: 0, Errors: 0, Skipped: 0

We are also about to test this live on the manabrew app for stability.
The following cards, made up of edge cases, loads fine under lazy loading:

Lim-Dûl's Vault
Fire // Ice
Start // Fire
A-Acererak the Archlich
+2 Mace
Rocket-Powered Goblin Glider
Stitcher's Wings
Mintstrosity
K9, Mark I
Archangel Avacyn
Vhal, Eager Scholar

@Jetz72 Jetz72 added the CardDB Loading, storing, and searching CardRules and PaperCard data label Sep 2, 2026

@tool4ever tool4ever left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this still breaks matrix decks: ba71e6a

it would be acceptable to just skip their loading like in that old commit for now, since it's probably better if we leave the user facing preference disabled at this point anyway 🤔

@JacopoMadaluni

JacopoMadaluni commented Sep 2, 2026

Copy link
Copy Markdown
Author

Hey @tool4ever thanks for the speedy review.

I think the fix was a one liner in the last commit.
In trying to understand the problem, I did the following:

  1. ran the app with lazy mode
forge/forge-gui ❯ java -jar ../forge-gui-desktop/target/forge-gui-desktop-*-jar-with-dependencies.jar
Error handling registered!
19:54:59 [INFO ] GuiBase: ##########################################
19:54:59 [INFO ] GuiBase: APP: Forge v.2.0.15-SNAPSHOT-09.02
19:54:59 [INFO ] GuiBase: Java: 17.0.18 (Homebrew)
19:54:59 [INFO ] GuiBase: OS: Mac OS X 15.5 aarch64
19:54:59 [INFO ] GuiBase: RAM: 4608 MB max, 12 CPUs
19:54:59 [INFO ] GuiBase: ##########################################
Language 'en-US' loaded successfully.
(ThreadUtil first call): Running with priority 5
Lazy card database: indexed 34923 card names from 33668 files in 3565 ms
Upcoming set Star Trek (TRK) dated in the future. All `upcoming` cards will be added to this set with unknown rarity.
Upcoming set Star Trek (TRK) dated in the future. All `upcoming` cards will be added to this set with unknown rarity.
Read cards: 839 files in 0 ms (8 parts) using thread pool

And indeed it seems to be skipping some decks? (I'm not sure I fully understand)

Screenshot 2026-09-02 at 20 01 18
  1. Running back eager, they come back:
Screenshot 2026-09-02 at 20 04 17

If I understsand correctly, it's not so much that it was "broken", is that these decks naturally will lazily load the whole db, defeating the purpose of the flag.
Let me know if I got it wrong!

@tool4ever

Copy link
Copy Markdown
Contributor

hmn I think they contain mostly their own serialized PaperCard, didn't seem to trigger any full load
but I actually couldn't start any game with one of these decks anymore
also if you just remove the whole folder the feature will obviously be broken 🤷‍♂️

you should probably test with other features/modes a bit too so any other wrong assumptions in the code might get uncovered - isolated suite alone isn't enough for such a complex mechanism

@JacopoMadaluni

JacopoMadaluni commented Sep 2, 2026

Copy link
Copy Markdown
Author

Yea I got confused on the folder cos I originally misunderstood the problem.
The decks are getting ignored like you suggested now.
I'm gonna take some time to test pathways on the forge app itself. Is there anything specific I should watch out for?
I'm planning to test the default decks, some of my own, quest, and aventure, although I'd expect adventure to have many paths that defeat lazy mode's purpose.
Allow me maybe an extra evening to come back to you :)

@tool4ever

Copy link
Copy Markdown
Contributor

sure no hurry, I doesn't have to work with everything as long as we understand the limits clearly

@JacopoMadaluni

Copy link
Copy Markdown
Author

Hey @tool4ever.
I've given a strong go to the app booted in lazy mode.
As far as I tested, the system is working pretty well. No crashes so far.

  • Every deck I played, versus the AI, on a new quest, and on puzzles worked as expected.
  • The deck editor loaded fine as well

This is a log I added on my local build counting every 1000 lazily loaded cards:

Read cards: 839 files in 0 ms (8 parts) using thread pool
Lazy card database: 1000 cards loaded via lazy lookups (latest: Blooming Marsh)
    from: forge.deck.CardPool.add(CardPool.java:107)
Lazy card database: 2000 cards loaded via lazy lookups (latest: Shredded Sails)
    from: forge.deck.CardPool.add(CardPool.java:107)
Lazy card database: 3000 cards loaded via lazy lookups (latest: Meandering River)
    from: forge.deck.CardPool.add(CardPool.java:107)
Lazy card database: 4000 cards loaded via lazy lookups (latest: Vampiric Tutor)

I think this is expected behaviour, since I imported quite a bit of decks from some of the archives.

When a path needs getAllCards() it correctly eagerly loads it all. This happens in some areas of the deck editor right now.

Lazy card database: loading all remaining cards

After they all load it essentially becomes functionally equivalent to eager mode.

After reading a bit I left out Adventure mode from the testing.
I don't believe it makes sense to boot adventure mode in lazy mode. Rewards and Shops need the full db. So I would just call it not supported.

I found one crash on the deck editor, but I believe it's pre-existing, as I could reproduce on eager mode on master as well:

java.util.ConcurrentModificationException
	at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1013)
	at java.base/java.util.ArrayList$Itr.next(ArrayList.java:967)
	at forge.gui.framework.DragCell.setSelected(DragCell.java:315)
	at forge.gui.framework.SRearrangingUtil.startRearrange(SRearrangingUtil.java:98)
	at forge.gui.framework.SRearrangingUtil$1.mousePressed(SRearrangingUtil.java:64)
	at java.desktop/java.awt.AWTEventMulticaster.mousePressed(AWTEventMulticaster.java:290)
	at java.desktop/java.awt.Component.processMouseEvent(Component.java:6623)
	at java.desktop/javax.swing.JComponent.processMouseEvent(JComponent.java:3389)
	at java.desktop/java.awt.Component.processEvent(Component.java:6391)
	at java.desktop/java.awt.Container.processEvent(Container.java:2266)
	at java.desktop/java.awt.Component.dispatchEventImpl(Component.java:5001)
	at java.desktop/java.awt.Container.dispatchEventImpl(Container.java:2324)
	at java.desktop/java.awt.Component.dispatchEvent(Component.java:4833)
	at java.desktop/java.awt.LightweightDispatcher.retargetMouseEvent(Container.java:4948)
	at java.desktop/java.awt.LightweightDispatcher.processMouseEvent(Container.java:4572)
	at java.desktop/java.awt.LightweightDispatcher.dispatchEvent(Container.java:4516)
	at java.desktop/java.awt.Container.dispatchEventImpl(Container.java:2310)
	at java.desktop/java.awt.Window.dispatchEventImpl(Window.java:2780)
	at java.desktop/java.awt.Component.dispatchEvent(Component.java:4833)
	at java.desktop/java.awt.EventQueue.dispatchEventImpl(EventQueue.java:775)
	at java.desktop/java.awt.EventQueue$4.run(EventQueue.java:720)
	at java.desktop/java.awt.EventQueue$4.run(EventQueue.java:714)
	at java.base/java.security.AccessController.doPrivileged(AccessController.java:399)
	at java.base/java.security.ProtectionDomain$JavaSecurityAccessImpl.doIntersectionPrivilege(ProtectionDomain.java:86)
	at java.base/java.security.ProtectionDomain$JavaSecurityAccessImpl.doIntersectionPrivilege(ProtectionDomain.java:97)
	at java.desktop/java.awt.EventQueue$5.run(EventQueue.java:747)
	at java.desktop/java.awt.EventQueue$5.run(EventQueue.java:745)
	at java.base/java.security.AccessController.doPrivileged(AccessController.java:399)
	at java.base/java.security.ProtectionDomain$JavaSecurityAccessImpl.doIntersectionPrivilege(ProtectionDomain.java:86)
	at java.desktop/java.awt.EventQueue.dispatchEvent(EventQueue.java:744)
	at java.desktop/java.awt.EventDispatchThread.pumpOneEventForFilters(EventDispatchThread.java:203)
	at java.desktop/java.awt.EventDispatchThread.pumpEventsForFilter(EventDispatchThread.java:124)
	at java.desktop/java.awt.EventDispatchThread.pumpEventsForHierarchy(EventDispatchThread.java:113)
	at java.desktop/java.awt.EventDispatchThread.pumpEvents(EventDispatchThread.java:109)
	at java.desktop/java.awt.EventDispatchThread.pumpEvents(EventDispatchThread.java:101)
	at java.desktop/java.awt.EventDispatchThread.run(EventDispatchThread.java:90)

Please let me know if you'd like to test anything else.

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

Labels

CardDB Loading, storing, and searching CardRules and PaperCard data Performance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants